Compare commits
62 Commits
93ccc8c449
...
v1.20.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e03d22e4 | |||
| d8d1b06eee | |||
| c16008e67a | |||
| ff55d5de83 | |||
| 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 | |||
| a41281c495 | |||
| 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 |
@@ -30,7 +30,7 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
|
||||
|
||||
## Native Android build (Capacitor)
|
||||
|
||||
The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
|
||||
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
|
||||
@@ -234,9 +234,9 @@ The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui`
|
||||
`--pg1-user=scrabble`.
|
||||
- **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
|
||||
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
|
||||
App, server-side confidential code exchange.
|
||||
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
||||
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
|
||||
offline-first, RuStore).
|
||||
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
|
||||
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
#
|
||||
# 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) and
|
||||
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
|
||||
# runner host needs nothing pre-installed.
|
||||
# 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 }}"
|
||||
|
||||
@@ -522,10 +522,21 @@ jobs:
|
||||
# (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
|
||||
# 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)"
|
||||
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"
|
||||
else
|
||||
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
|
||||
# cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
|
||||
# now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
|
||||
# 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)"
|
||||
docker logs --tail 50 scrabble-renderer || true
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
@@ -533,6 +544,35 @@ jobs:
|
||||
exit 1
|
||||
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
|
||||
run: |
|
||||
set -u
|
||||
|
||||
@@ -62,15 +62,86 @@ client-only (no server change).
|
||||
|
||||
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:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
|
||||
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
|
||||
icons + splashes. Superseded by the icon rebrand below.
|
||||
**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
|
||||
@@ -181,11 +252,10 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
||||
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.
|
||||
|
||||
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
|
||||
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
|
||||
the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed
|
||||
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
|
||||
(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -334,8 +404,8 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
|
||||
```
|
||||
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
|
||||
(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`
|
||||
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
|
||||
- **Launcher icon:** DONE — generated from the brand master into the Capacitor layers
|
||||
`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/`)
|
||||
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
||||
@@ -695,6 +765,14 @@ Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
||||
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
||||
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
||||
|
||||
| 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**.
|
||||
The whole shipped icon set is sliced from **one master** — see
|
||||
[`brand/`](brand/) for the reference generator, the pinned font and the committed
|
||||
outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
|
||||
mark is constructed. The per-platform target map (which file goes where) is in
|
||||
[`../../docs/ICONS.md`](../../docs/ICONS.md).
|
||||
|
||||
```sh
|
||||
cd assets/icons
|
||||
|
||||
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
||||
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
||||
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
||||
|
||||
# 2. regenerate everything in ui/public/:
|
||||
node build/generate.js
|
||||
cd brand
|
||||
npm i opentype.js
|
||||
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
|
||||
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
|
||||
```
|
||||
|
||||
> 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>
|
||||
</form>
|
||||
</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>
|
||||
<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">
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
@@ -677,6 +677,15 @@ type FeedbackDetailView struct {
|
||||
// CatalogView is the product-catalog list page.
|
||||
type CatalogView struct {
|
||||
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
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||
t.Helper()
|
||||
all, err := pay.AdminCatalog(context.Background())
|
||||
all, err := pay.AdminCatalog(context.Background(), true)
|
||||
if err != nil {
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
||||
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
|
||||
// no method, so only values show; buying is gated server-side regardless.
|
||||
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
|
||||
for _, e := range entries {
|
||||
isPack := false
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx)
|
||||
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||
// 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
|
||||
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
// products the same as active ones (the admin list shows both).
|
||||
func SortAdminCatalog(products []AdminProduct) {
|
||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
||||
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))
|
||||
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
// 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.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"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
|
||||
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||
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
|
||||
for _, e := range entries {
|
||||
for _, e := range sorted {
|
||||
if isPackEntry(e) {
|
||||
packs = append(packs, e)
|
||||
} 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
|
||||
if len(packs) > 0 {
|
||||
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||
|
||||
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -19,11 +19,17 @@ import (
|
||||
// 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")
|
||||
|
||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||
// 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
|
||||
if err := postgres.SELECT(table.Product.AllColumns).
|
||||
FROM(table.Product).
|
||||
WHERE(where).
|
||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||
QueryContext(ctx, s.db, &prods); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -33,10 +33,12 @@ var priceFields = []struct {
|
||||
{"price_chip", "", payments.CurrencyChip},
|
||||
}
|
||||
|
||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
||||
// flag, and the inline create form.
|
||||
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||
// 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) {
|
||||
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 {
|
||||
s.consoleError(c, err)
|
||||
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,
|
||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||
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 {
|
||||
view.Products = append(view.Products, catalogRow(p))
|
||||
}
|
||||
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.
|
||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||
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 {
|
||||
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 {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
@@ -242,7 +263,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||||
// bundles, including archived ones — chips and tournament products are excluded).
|
||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||
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 {
|
||||
return fv
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
if s.payments != nil {
|
||||
gm.GET("/catalog", s.consoleCatalog)
|
||||
gm.POST("/catalog", s.consoleCreateProduct)
|
||||
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||
|
||||
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
zap.String("path", route),
|
||||
zap.Int("status", status),
|
||||
zap.Duration("latency", elapsed),
|
||||
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
||||
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
}
|
||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
||||
|
||||
# --- UI build args (baked into the gateway image) ---------------------------
|
||||
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_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
|
||||
|
||||
@@ -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_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_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_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_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://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_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. |
|
||||
@@ -241,7 +241,7 @@ 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. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
|
||||
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
|
||||
|
||||
@@ -150,6 +150,13 @@
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
||||
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
||||
# services themselves run in UTC regardless; this is about host-side log correlation.
|
||||
- name: Set the system timezone to UTC
|
||||
community.general.timezone:
|
||||
name: Etc/UTC
|
||||
|
||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||
|
||||
@@ -107,12 +107,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
# The public offer page is rendered by the render sidecar: it splices the live catalog price
|
||||
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
|
||||
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
|
||||
# disjoint from the landing/app paths so the catch-all below never shadows it.
|
||||
@offer path /offer /offer/*
|
||||
handle @offer {
|
||||
# The public legal pages are rendered by the render sidecar: the offer (with the live catalog
|
||||
# price list from the backend spliced into ui/legal/offer_ru.md), the privacy policy and the EULA
|
||||
# (both static markdown). Only these paths are exposed here — the sidecar's /render stays off this
|
||||
# allow-list, internal-only. Kept disjoint from the landing/app paths so the catch-all below never
|
||||
# shadows them.
|
||||
@legal path /offer /offer/* /privacy /privacy/* /eula /eula/*
|
||||
handle @legal {
|
||||
reverse_proxy renderer:8090
|
||||
}
|
||||
|
||||
|
||||
@@ -107,8 +107,10 @@ dropped). Horizontal scaling is explicit future work.
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
|
||||
(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
|
||||
@@ -116,10 +118,17 @@ dropped). Horizontal scaling is explicit future work.
|
||||
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** — always online: they are never fed an
|
||||
offline signal, and `offlineMode.active` is additionally 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.
|
||||
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
|
||||
`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
|
||||
@@ -142,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
|
||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
||||
(carried on the request context), so the backend records the real caller — the
|
||||
account's last-login IP, chat/feedback moderation, the access log — rather than
|
||||
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||
of 2 idle connections per host; otherwise the per-request connection churn
|
||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||
@@ -967,10 +979,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
|
||||
image option is not offered.
|
||||
|
||||
The same sidecar also serves the **public offer page** at `/offer/` — the one edge-exposed
|
||||
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared
|
||||
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited
|
||||
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it
|
||||
The same sidecar also serves the **public legal pages** — the offer at `/offer/`, the privacy
|
||||
policy at `/privacy/` and the EULA at `/eula/` — the edge-exposed routes on it (caddy routes them
|
||||
here; its `/render` stays internal). All three reuse the shared `ui/src/lib/offer.ts`
|
||||
`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
|
||||
`/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
|
||||
@@ -1365,7 +1381,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
|
||||
— 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
|
||||
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
|
||||
`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
|
||||
@@ -1446,9 +1464,10 @@ in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/
|
||||
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
|
||||
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/`
|
||||
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
|
||||
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
|
||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the public
|
||||
legal pages `/offer/`, `/privacy/` and `/eula/` (rendered by the `renderer` sidecar — the offer with
|
||||
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
|
||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||
@@ -1480,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
||||
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
||||
last-login IP, chat/feedback moderation and the access log — survives the
|
||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||
@@ -1536,8 +1556,8 @@ hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `
|
||||
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. Full plan + runbook:
|
||||
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
|
||||
with a host-provisioned Android SDK; RuStore upload is manual. Runbook:
|
||||
[`../deploy/README.md`](../deploy/README.md).
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
|
||||
@@ -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
|
||||
(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
|
||||
shell is `noindex` — the landing is the only indexable page. The footer links to the **public
|
||||
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
|
||||
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
|
||||
`ui/legal/offer_ru.md` with 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.
|
||||
shell is `noindex` — the landing is the only indexable page. The footer links to the three **legal
|
||||
documents** — the **user agreement** (`/eula/`), the **privacy policy** (`/privacy/`) and the
|
||||
**public offer** (`/offer/`) — and, beside them, **feedback** (the Telegram bot the documents name as
|
||||
the seller's contact). Each is **bilingual (RU/EN)** with a language + theme switcher and is rendered
|
||||
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
|
||||
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
|
||||
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
|
||||
"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
|
||||
(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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -317,6 +325,17 @@ offline on its own, and restoring the network returns it online by itself. There
|
||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||
offline, so you are never interrupted for a hiccup.
|
||||
|
||||
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||||
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
|
||||
@@ -537,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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -32,12 +32,13 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
|
||||
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
|
||||
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
|
||||
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
|
||||
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
|
||||
сейчас в продаже — без передеплоя.
|
||||
`noindex` — индексируется только посадочная страница. В подвале — ссылки на три **юридических
|
||||
документа** — **пользовательское соглашение** (`/eula/`), **политику конфиденциальности**
|
||||
(`/privacy/`) и **публичную оферту** (`/offer/`) — и рядом на **обратную связь** (тот самый
|
||||
Telegram-бот, который документы указывают как контакт Продавца). Каждый — **двуязычный (RU/EN)** с
|
||||
переключателем языка и темы, рендерится из исходников `ui/legal/*_{ru,en}.md` сайдкаром-рендерером;
|
||||
оферта дополнительно подставляет **перечень стоимости** (§4.4), формируемый из живого каталога
|
||||
товаров, поэтому опубликованные цены всегда совпадают с тем, что сейчас в продаже — без передеплоя.
|
||||
|
||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
||||
@@ -76,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||
тихого повтора «не удалось
|
||||
загрузить» действует и внутри VK.
|
||||
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
|
||||
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
|
||||
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
|
||||
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
|
||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||
@@ -232,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
|
||||
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
|
||||
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||
@@ -323,6 +329,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||
Ничего не отправляется;
|
||||
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
@@ -551,7 +567,9 @@ high-rate флага. С карточки пользователя операт
|
||||
|
||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
||||
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
|
||||
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
|
||||
сообщения. То есть незарегистрированного новичка, написавшего в чат,
|
||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||
в чате** — роль `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-метод**
|
||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||
|
||||
@@ -40,7 +40,7 @@ tests or touching CI.
|
||||
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** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
|
||||
**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 →
|
||||
|
||||
@@ -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
|
||||
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),
|
||||
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}`
|
||||
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
|
||||
|
||||
@@ -139,6 +139,26 @@ func platformFromContext(ctx context.Context) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// clientIPCtxKey types the request-context slot the originating client IP rides in.
|
||||
type clientIPCtxKey struct{}
|
||||
|
||||
// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as
|
||||
// X-Forwarded-For on every downstream backend request — so the backend records the real caller
|
||||
// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's
|
||||
// own connection. An empty IP leaves ctx unchanged, so no header is sent.
|
||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||
if ip == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, clientIPCtxKey{}, ip)
|
||||
}
|
||||
|
||||
// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none.
|
||||
func clientIPFromContext(ctx context.Context) string {
|
||||
ip, _ := ctx.Value(clientIPCtxKey{}).(string)
|
||||
return ip
|
||||
}
|
||||
|
||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||
@@ -160,6 +180,13 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
||||
if userID != "" {
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
}
|
||||
// The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the
|
||||
// explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set
|
||||
// once per request in the Connect edge) — so the backend never falls back to the gateway's own
|
||||
// connection address.
|
||||
if clientIP == "" {
|
||||
clientIP = clientIPFromContext(ctx)
|
||||
}
|
||||
if clientIP != "" {
|
||||
req.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
|
||||
@@ -122,3 +122,44 @@ func TestXPlatformInjection(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
||||
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
||||
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
||||
// gateway's own connection. Absent when no client IP is set.
|
||||
func TestXForwardedForInjection(t *testing.T) {
|
||||
var gotXFF string
|
||||
var hadHeader bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotXFF = r.Header.Get("X-Forwarded-For")
|
||||
_, hadHeader = r.Header["X-Forwarded-For"]
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
||||
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
||||
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if gotXFF != "203.0.113.7" {
|
||||
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no client IP omits the header", func(t *testing.T) {
|
||||
hadHeader = true
|
||||
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if hadHeader {
|
||||
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,7 +165,12 @@ func DefaultRateLimit() RateLimitConfig {
|
||||
// because multi-device play tripped the old 120/40.
|
||||
UserPerMinute: 300, UserBurst: 80,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -418,6 +418,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||
}
|
||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||
// Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every
|
||||
// downstream REST call for this request — the account's last-login IP, chat/feedback moderation and
|
||||
// the backend access log, not only the chat/feedback calls that pass it explicitly.
|
||||
ctx = backendclient.WithClientIP(ctx, clientIP)
|
||||
|
||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||
if op.Auth {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
)
|
||||
|
||||
@@ -44,3 +45,20 @@ func TestPerWindow(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,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
|
||||
`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
|
||||
`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
|
||||
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
|
||||
|
||||
@@ -285,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -405,6 +411,14 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleSuccessfulPayment(ctx, update.Message)
|
||||
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 —
|
||||
// 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.
|
||||
@@ -421,6 +435,24 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
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
|
||||
// bot-link client backing it is built after the bot).
|
||||
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
|
||||
// scripted getChatMember status, and records restrictChatMember calls.
|
||||
// scripted getChatMember status, and records restrictChatMember and unpinChatMessage calls.
|
||||
type chatAPI struct {
|
||||
memberStatus string // the status getChatMember reports (default "left")
|
||||
restricts []restrictCall
|
||||
unpins []unpinCall
|
||||
}
|
||||
|
||||
type restrictCall struct {
|
||||
@@ -30,6 +31,13 @@ type restrictCall struct {
|
||||
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) {
|
||||
switch {
|
||||
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)
|
||||
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
||||
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:
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestExecutorChatGateInvalidExternalID(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)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
||||
@@ -178,7 +178,7 @@ func TestExecutorCreateInvoice(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
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).
|
||||
BotUsername string
|
||||
// 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
|
||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||
BotLinkURL string
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
||||
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
|
||||
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
|
||||
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()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBot: %v", err)
|
||||
@@ -126,7 +126,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
||||
if c.BotUsername != "ScrabbleBot" {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
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),
|
||||
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||
@@ -41,17 +41,17 @@ func TestPromoTextLocalization(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLaunchURLAppendsStartapp(t *testing.T) {
|
||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
||||
if got := b.launchURL(""); got != "https://t.me/bot/app" {
|
||||
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||
if got := b.launchURL(""); got != "https://telegram.me/bot/app" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
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)")
|
||||
@@ -84,7 +84,7 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
api := &fakeAPI{}
|
||||
srv := httptest.NewServer(api)
|
||||
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 {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
if api.chatID != "42" {
|
||||
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)
|
||||
}
|
||||
if strings.Contains(api.replyMarkup, "web_app") {
|
||||
@@ -113,7 +113,7 @@ func TestHandleStartIgnoresGroup(t *testing.T) {
|
||||
api := &fakeAPI{}
|
||||
srv := httptest.NewServer(api)
|
||||
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 {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ ENV APP_VERSION=${VERSION}
|
||||
WORKDIR /app
|
||||
COPY --from=build /src/renderer/node_modules ./node_modules
|
||||
COPY --from=build /src/renderer/dist ./dist
|
||||
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
|
||||
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
|
||||
# price list is fetched from the backend at request time and spliced in.
|
||||
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
|
||||
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs renderer/src/legal.mjs ./src/
|
||||
# The public legal pages: the owner-edited markdown, read once at boot. GET /offer/ also splices in
|
||||
# the live price list fetched from the backend at request time; GET /privacy/ and GET /eula/ are
|
||||
# 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
|
||||
EXPOSE 8090
|
||||
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
|
||||
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
|
||||
[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
|
||||
|
||||
@@ -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`
|
||||
(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`)
|
||||
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
|
||||
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
|
||||
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
|
||||
and spliced in at the `<#pricing_template#>` marker of both language sources (`offer_ru.md` /
|
||||
`offer_en.md`), then rendered as one bilingual page by the shared `ui/src/lib/offer.ts`
|
||||
`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).
|
||||
|
||||
The service renders and nothing else: for the PNG, authentication, the participant check and the
|
||||
@@ -32,10 +40,14 @@ page.
|
||||
```sh
|
||||
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
|
||||
# (allowBuilds) or the native binary is silently never fetched
|
||||
pnpm test # bundles, then node --test (PNG smoke + offer splice)
|
||||
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
|
||||
# reachable backend (else the boot read / the price fetch fail):
|
||||
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
|
||||
pnpm test # bundles, then node --test (PNG smoke + offer/legal render)
|
||||
# local run on :8090 (RENDERER_PORT overrides). The server reads all three legal sources at boot, so
|
||||
# point each at its ui/legal source (the baked paths do not exist in a local checkout); /offer/ also
|
||||
# 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
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// 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):
|
||||
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
|
||||
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
|
||||
// used to invoke, now server-side so the live catalog price list can be spliced in.
|
||||
// - renderOfferHtml / renderLegalHtml: the public legal pages — GET /offer/ (with the live
|
||||
// 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 { 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
|
||||
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
|
||||
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
|
||||
// The public-offer page renderer: splices the live catalog price list into BOTH language sources of
|
||||
// the owner-edited offer markdown at the pricing marker, and renders the bilingual /offer/ page with
|
||||
// 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';
|
||||
|
||||
// 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
|
||||
// tables before rendering.
|
||||
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),
|
||||
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
|
||||
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
|
||||
export function renderOffer(offerMarkdown, pricingTables) {
|
||||
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
|
||||
// rendered to a self-contained bilingual document. The replacement is literal (a function replacer)
|
||||
// so a "$" in a product title is never read as a replacement pattern; a missing marker leaves the
|
||||
// text as is.
|
||||
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:
|
||||
//
|
||||
// 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
|
||||
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here)
|
||||
// GET /offer → 301 /offer/
|
||||
// GET /healthz → 200
|
||||
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
|
||||
// price list spliced in; caddy routes /offer/ here)
|
||||
// GET /privacy/ → text/html (the privacy policy: ui/legal/privacy_ru.md, static)
|
||||
// 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
|
||||
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
|
||||
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
|
||||
// markdown; no user input reaches it.
|
||||
// public URL live in the backend). The legal pages (/offer/, /privacy/, /eula/) are reachable from the
|
||||
// edge but read-only and unprivileged: /offer/ splices the price list fetched from the backend's
|
||||
// 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 { readFileSync } from 'node:fs';
|
||||
import { renderRequest } from './render.mjs';
|
||||
import { renderOffer } from './offer.mjs';
|
||||
import { renderPrivacy, renderEula } from './legal.mjs';
|
||||
|
||||
const PORT = Number(process.env.RENDERER_PORT || 8090);
|
||||
// A render request is a finished game's journal — generously capped.
|
||||
const MAX_BODY = 2 * 1024 * 1024;
|
||||
|
||||
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the
|
||||
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge
|
||||
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked
|
||||
// path for a local run (point it at ../ui/legal/offer_ru.md).
|
||||
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8');
|
||||
// Each legal page is bilingual (RU + EN); both markdown sources are baked into the image
|
||||
// (renderer/Dockerfile) and read once at boot. The offer's price list is the only dynamic part
|
||||
// (fetched per request from the backend's internal endpoint and spliced in); privacy + eula carry no
|
||||
// dynamic data. The RENDERER_*_MD / RENDERER_*_EN_MD env vars override the baked paths for a local run.
|
||||
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 =
|
||||
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
|
||||
|
||||
@@ -68,12 +83,28 @@ const server = createServer(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && path === '/offer/') {
|
||||
const html = renderOffer(OFFER_MD, await fetchOfferPricing());
|
||||
const html = renderOffer(OFFER_MD, OFFER_EN_MD, await fetchOfferPricing());
|
||||
res
|
||||
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
.end(html);
|
||||
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') {
|
||||
const png = await renderRequest(JSON.parse(await readBody(req)));
|
||||
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
|
||||
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml
|
||||
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts);
|
||||
// here we assert the substitution and that the tables reach the rendered HTML.
|
||||
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list markdown
|
||||
// into BOTH language sources of the offer at the pricing marker and renders them with the shared
|
||||
// renderOfferHtml (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/
|
||||
// (offer.test.ts); here we assert the substitution reaches both languages and the tables render.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderOffer, pricingMarker } from '../src/offer.mjs';
|
||||
@@ -11,19 +11,21 @@ const tables =
|
||||
'| --- | --- | --- | --- |\n' +
|
||||
'| 50 «Фишек» | 200.00 | 30 | 100 |';
|
||||
|
||||
test('splices the price list into the offer and renders the tables to HTML', () => {
|
||||
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
|
||||
const html = renderOffer(md, tables);
|
||||
// The marker is gone and the projected table reached the document as a real HTML table.
|
||||
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted');
|
||||
test('splices the price list into both language sources and renders the tables', () => {
|
||||
const ru = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
|
||||
const en = `# Public offer\n\n**4.4.** Cost of the Goods:\n\n${pricingMarker}\n`;
|
||||
const html = renderOffer(ru, en, tables);
|
||||
// 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('<td>200.00</td>'), 'a rouble price cell 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>'));
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -29,13 +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`
|
||||
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
|
||||
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.
|
||||
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`; see `ANDROID_PLAN.md`.
|
||||
`.gitea/workflows/android-build.yaml`.
|
||||
|
||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
||||
|
||||
@@ -53,7 +53,7 @@ captures/
|
||||
.idea/navEditor.xml
|
||||
|
||||
# Keystore files
|
||||
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk).
|
||||
# Release signing material must never be committed (keystore loss/leak risk).
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background>
|
||||
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
|
||||
</background>
|
||||
<foreground>
|
||||
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
<background android:drawable="@mipmap/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
||||
</adaptive-icon>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 913 B After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 754 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 333 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.6 KiB |