Compare commits

...

17 Commits

Author SHA1 Message Date
Ilia Denisov 6c0daeb177 fix(vk): persist settings via VK Bridge storage; hide competing sign-in in Mini App hosts
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m28s
CI / conformance (pull_request) Successful in 16s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m6s
VK moderation follow-ups.

1. Settings (theme, language, board style, reduce-motion, zoom, first-run
   coachmark flags) were lost between reloads in VK's desktop iframe: Firefox
   partitions/blocks IndexedDB and localStorage in a cross-origin iframe, so the
   browser-local store is empty on every reload (a plain tab / PWA keeps them —
   first-party storage — and Telegram has its own CloudStorage sync). Mirror the
   prefs (plus the locale, which has no other durable client home on VK) and the
   coachmark flags to VK Bridge storage (VKWebAppStorageSet/Get), which travels
   over postMessage to vk.com and survives; reconcile them on the VK launch
   before the first paint. The VK identity re-derives from the signed launch
   params each load, so the values reload onto the same account.

2. Inside a proprietary Mini App host (VK or Telegram) the profile now surfaces
   only email linking — both the Telegram and VK link/unlink entries are hidden
   (signInProvidersVisible), so a competing sign-in is never advertised (a
   platform ToS requirement; VK forbids showing a Telegram login, mirrored in
   Telegram for VK). A provider linked earlier on the web stays attached and is
   managed from the web; web/native builds show both.

Tests: vkprefs codec + signInProvidersVisible unit tests. Docs: ARCHITECTURE,
FUNCTIONAL (+_ru). No schema/wire change.
2026-07-15 22:53:58 +02:00
developer b285e27d55 Merge pull request 'docs(apple): native iOS/iPadOS app plan' (#277) from feature/apple-ios-plan into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Has been skipped
2026-07-15 17:27:30 +00:00
Ilia Denisov 3dfc0932ef docs(apple): drop remaining [FIXED ...] header tags (sections 8, 12)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-15 19:26:36 +02:00
Ilia Denisov e04e97fb66 docs(apple): drop [FIXED] tags from the plan
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
Decisions stay recorded; the tag implied false permanence. Open items
keep their [OPEN / TRACKED] / [GATE] markers.
2026-07-15 19:24:45 +02:00
Ilia Denisov ebc413f5e5 docs(apple): add native iOS/iPadOS app plan (apple/PLAN.md)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
Design/continuity doc for the from-scratch SwiftUI iPhone+iPad app: Mac
build model, engineering decisions (min iOS 18, apple/ dir, XcodeGen+SPM,
@Observable, Swift 6 strict concurrency, SF Symbols, runtime-vector tiles),
the five determining optimizations, storage/audio/testing/crash choices,
region-aware payment seam with tracked App Store risks, and the full A-L
+ game-screen UX inventory. Delivery staged; several items tracked for
follow-up sessions.
2026-07-15 19:19:31 +02:00
developer d48797e297 Merge pull request 'native polish 2: two-robots fix, guest offline-host UX, Guest###### name, silent+fast native boot, minSdk 29' (#275) from feature/native-polish-2 into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-15 00:26:27 +00:00
Ilia Denisov fe29c6fe58 chore(android): raise minSdk to 29 (Android 10)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bundled WebView build is only compatible from Android 10 (API 29); on Android 9 it lands on
the in-app unsupported-engine screen. Declaring minSdk 29 lets RuStore/Google Play block older
devices at install rather than shipping a known-incompatible build.
2026-07-15 02:17:13 +02:00
Ilia Denisov 3429dbec5f fix(boot): native offline-first launch is silent and fast (Android 10)
Two native offline-first boot glitches (seen on Android 10):
- bootOffline forced offlineNoNetwork, so its first successful reconcile healed to online with a
  spurious "back online" toast on a first launch that actually had connectivity. The boot-assumed
  offline is now PROVISIONAL: the first probe confirms it — a success heals silently (nothing to
  come back from), a failure turns it into a real offline whose later recovery does toast.
- A native launch with a cached guest session (no email) skipped the offline short-circuit and hung
  ~25 s on adoptSession's retrying profile fetch in airplane mode. The native channel is offline-first,
  so it now takes the same reachability-gated fallback (~3 s bound) as an installed email PWA.
2026-07-15 02:17:13 +02:00
Ilia Denisov 74026223ee feat(account): give a fresh guest a distinct "Guest######" display name
Every guest showed a bare "Guest" to opponents (e.g. in a random match). Mint the display name
as "Guest" + a random six-digit suffix at provisioning, so guests are distinguishable. Not an
identifier and not unique — a label only, so collisions are harmless.
2026-07-15 02:17:13 +02:00
Ilia Denisov 896b7f57a7 feat(newgame): skip the take-a-seat prompt for a guest offline host
A guest host has no meaningful display name to seat, so the offline 'with friends' flow's
"do you take a seat?" prompt only produced a nameless seat. For a guest the prompt is skipped:
after the master PIN, the two empty player seats show directly. A durable host is still asked.
2026-07-15 02:17:13 +02:00
Ilia Denisov aa803260a1 fix(game): recognise the device-local guest as "me" in the game header
The game header identified the viewer's seat only by app.session.userId, but a device-local
game (vs_ai / hotseat) created offline is seated under the local guest id (the lobby already
matches both). After a merge switched the active account to a durable id, the human seat matched
neither, so a vs_ai game showed BOTH seats as robots (the turn still worked — it is seat-index
based). seatName now matches app.session.userId OR localGuestId, like the lobby. Completes the
v1.22.0 repointLocalGameSeats fix, which only covered games seated under the retired session id.
2026-07-15 02:17:13 +02:00
developer 8758cbd35d Merge pull request 'native polish: guest-locale banner, seamless guest sign-in (+local-game fix), 'Opponent's turn' label, deploy-authoritative dictionary' (#273) from feature/native-polish-and-dict-deploy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m16s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m49s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 23:07:05 +00:00
Ilia Denisov 1d306ec0fc chore(ui): change hints symbol for backward unicode compatibility
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
2026-07-15 00:56:47 +02:00
Ilia Denisov 513b772cd4 feat(game): show 'Opponent's turn' instead of the opponent's name in the turn strip
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
When it is not the viewer's turn in an online PvP or vs_ai game, the under-board status
now reads the generic 'Opponent's turn' (game.opponentsTurn) rather than the opponent's
display name or the robot mark, so whose turn it is reads uniformly. The hotseat branch
still names the seat to move (whose device-turn it is matters there), and the seat row
below continues to name each player.
2026-07-15 00:34:58 +02:00
Ilia Denisov da25eac070 feat(link): auto-merge a guest initiator into its durable account (seamless sign-in)
When a guest links an identity (email/TG/VK) that already belongs to a durable account,
the guest-primary rule already made the durable account the survivor and switched the
session — but the client still showed an irreversible 'merge two accounts?' confirmation,
which is nonsense from the user's side (they are simply signing into their account). The
confirm step now merges inline for a GUEST initiator and returns the completed merge (the
switched token); a durable initiator still gets the explicit confirmation (consolidating
two real accounts is consequential). The active-game guard still refuses, surfaced as the
clear error.merge_active_game_conflict message rather than swallowed.

Also fixes the merged-away device-local games: their human/host seat was recorded under
the retired account id, so after the switch the lobby and game header could not identify
'me' and showed every seat as an opponent (the game still played — turn logic is
seat-index based). applyLinkResult now re-points local game seats from the retired id to
the survivor (repointLocalGameSeats).

Docs: FUNCTIONAL.md (+_ru).
2026-07-15 00:34:58 +02:00
Ilia Denisov 48614e622d feat(dict): make the build's dictionary version deploy-authoritative
The dictionary lives on a persistent volume seeded from the image once and never
re-seeded, so a redeploy that bumped BACKEND_DICT_VERSION left the new DAWGs
unreachable (the volume mount shadows the image copy) and the active version stuck
at whatever the admin console last installed — a native offline-first client then
fetched the server's older pinned version over the network instead of using its
bundled dict.

On boot the backend now DELIVERS the build version onto the volume add-only, from a
second unshadowed image copy at BACKEND_DICT_SEED_DIR, into DICT_DIR/<version>/ when
absent (the flat seed and prior uploads untouched, so in-flight games keep theirs),
and InitActiveVersion makes the build version active — except a console-installed
version NEWER than the build (compared numerically) is not downgraded by a restart.
The .seed_version guard still protects the flat seed's label (a bump is a new
subdirectory, never a relabel). The console stays for out-of-band updates.

Docs: ARCHITECTURE.md §5, deploy/README.md, seedmarker.go. Tests: engine.DeliverVersion
(add-only, idempotent, flat-seed skip), compareDictVersions, and the dictionary-update
integration test's deploy-delivers path.
2026-07-15 00:21:20 +02:00
Ilia Denisov f8aeec8008 fix(account): seed a fresh guest's interface language from the client locale
A guest was provisioned with only its detected time zone; its PreferredLanguage
fell to the 'en' column default. The client already sends its locale in the guest
login (GuestLoginRequest.locale), but the gateway dropped it and ProvisionGuest
took no language — so a Russian user's brand-new native guest got English
language-dependent server content (the ad banner, bot messages) until the client's
later language reconcile. Read the locale in the gateway guest handler, thread it
through GuestAuth -> ProvisionGuest, and seed PreferredLanguage from it (validated;
an unsupported/absent value keeps the 'en' default). Wire field already present —
no schema change.
2026-07-15 00:06:23 +02:00
46 changed files with 1691 additions and 159 deletions
+698
View File
@@ -0,0 +1,698 @@
# apple/ — native iOS/iPadOS app plan
Native **SwiftUI** application for iPhone and iPad, written **from scratch** (not a Capacitor
wrapper, unlike the Android app under `ui/android`). This document is the **source of truth and
continuity** for the effort: it lives in the repo so it travels to whatever machine does the work —
in particular the **Mac Claude session** that does the actual building. Read it first; append to it
as decisions land. Nothing here is final until built and verified; any decision can be reopened.
Items still open carry an **[OPEN / TRACKED]** or **[GATE]** marker.
Chat with the owner is in Russian per the user-level guide; this doc, like all repo docs, is in
**English**.
---
## 0. Working model — two contexts
- **Mac (primary).** All real development happens in a **Claude session on the Mac**: SwiftUI code,
`xcodebuild`, Simulator, tests, screenshots, signing, TestFlight/App Store. The Apple toolchain is
native there.
- **Linux dev-host (this repo's usual environment).** Used only to **lay groundwork for CI** (a macOS
runner, later) and to edit platform-agnostic Swift packages / docs. It **cannot** build or run an
iOS app — SwiftUI/UIKit and the iOS SDK exist only inside Xcode on macOS. (Swift the language runs
on Linux, but that is server-side Swift, irrelevant to iOS UI.)
Consequence: the heavy loop (build/sim/screenshot) is Mac-only; keep the SwiftUI shell thin and push
logic into plain Swift packages — good for testability regardless of host.
---
## 1. Tooling to install / verify on the Mac host
Do this first on the Mac before any code. The owner already has Xcode + Command Line Tools + a
simulator installed generally; the steps below are mostly **verify**, a few are **install**.
### Verify (already present, confirm versions)
- **Xcode 26** (latest) — we always build against the **newest SDK** (this is what grants the iOS 26
Liquid Glass appearance to standard controls; see §3). Confirm `xcodebuild -version`.
- **Command Line Tools** — `xcode-select -p` points at the Xcode 26 toolchain.
- **Swift 6** toolchain (bundled with Xcode 26) — `swift --version`.
- **Simulator runtimes** — need **both**:
- **iOS 18** runtime (our minimum deployment target — verify the app runs/looks correct there),
- **iOS 26** runtime (latest — verify Liquid Glass appearance).
Check under Xcode ▸ Settings ▸ Platforms; install any missing runtime there.
### Install (Homebrew, sudo-free where possible)
- **XcodeGen** — `brew install xcodegen`. Generates the `.xcodeproj` from a declarative `project.yml`
(see §4). This is the project-management tool of record.
- **swiftlint** and/or **swift-format**`brew install swiftlint swift-format`. Lint/format gate.
- *(optional, CI ergonomics)* **xcbeautify**`brew install xcbeautify`, prettifies `xcodebuild`
logs. Not required for local work.
### No install needed (SPM dependencies, pulled by the build)
- **swift-snapshot-testing** (Point-Free, open-source) — snapshot tests, added as an SPM package.
- **MetricKit** — first-party Apple framework for crash/hang/diagnostic capture; nothing to install.
### Deferred / not now
- **Apple Developer account** — in process; see the gate in §11. Not required to start (Simulator work
is unblocked without it).
- **fastlane / signing automation** — only when we reach device builds / CI / release. Revisit then.
- **macOS CI runner** (Gitea Actions on a Mac, or a cloud Mac) — the Linux-side groundwork; designed
later.
---
## 2. Fixed decisions
| Topic | Decision |
|---|---|
| Language / UI | Native **SwiftUI**, from scratch; **not** Capacitor. |
| Targets | **iPhone first**, then **iPad** (iPadOS) as a first-class target: reuse controls, adapt via layout. Possibly macOS/Catalyst far later — do not design for it now, but the `apple/` name leaves the door open. |
| Minimum OS | **iOS 18** (reaches ~A12 devices, iPhone XS/XR and newer; wide parity). Always **build with the latest SDK** (Xcode 26). |
| Directory | **`apple/`** in this monorepo (covers iPhone/iPad/future Mac; `ios/` too narrow, `swiftui/` names a framework not a platform). CI + docs stay co-located with the engine/wire. |
| Project tooling | **XcodeGen** (`project.yml`) + **SPM** modules. Not a hand-managed `.xcodeproj` (merge-hostile `project.pbxproj`, opaque, non-reproducible). Tuist only if modularization outgrows XcodeGen. |
| State management | **`@Observable`** (Observation framework), the idiomatic first-party default. **Not TCA** (heavy boilerplate, steep curve, large dependency; its benefits do not pay off at our size). Introduce a light unidirectional pattern by hand only where genuinely needed. |
| Concurrency | **Swift 6 strict concurrency** from the start (actors / `Sendable`) — cheaper to write new than to retrofit. |
| Localization / a11y | **First-class from the first screen**: RU/EN via String Catalogs, Dynamic Type, Dark Mode, Reduce Motion. Cheap now, expensive to retrofit. |
| Design relationship to web | **Not a mirror of the web app.** Board + game mechanics are preserved; the rest is designed fresh with iOS-native idioms. |
| UI philosophy | **System controls by default, custom only when necessary.** (Also "modern for free": Liquid Glass auto-applies to standard chrome on iOS 26.) |
| Auth | **Sign in with Apple** (required by Apple when any third-party social login is offered) **plus** email / VK / Telegram / … in the usual stacked-button login. Backend already has VK/TG/email; Apple is the only new provider. Details deferred to the descriptive phase. |
| Payments | Target **both** external rails (Robokassa / analogs, primary for RU) **and** Apple IAP, behind a **region-aware payment seam**. See §9 for the out-of-region reject risk (tracked separately). |
| Bundle id | Reuse **`ru.eruditgame.app`** (Apple/Google namespaces are independent; one bundle id for the universal iPhone+iPad app). |
| Design scope vs delivery | **Design/inventory scope = the full (online) app** — describe every moving part up front (online has more by design). **Implementation/delivery may stage** (stub online surfaces + stub engine first, wire real behind the same seams later). These are different axes; do not shrink the *design* to offline-first. |
| Crash reporting | **MetricKit only** (first-party, self-hosted upload; see §8). No PLCrashReporter, no Sentry for now. |
| Analytics | **None.** Nothing is tracked. No speculative "entry points" either (YAGNI; cheap to add later since actions centralize in `@Observable` models). Monitoring stays backend-only, as today. |
| Onboarding | **TipKit** (first-party, iOS 17+) for the coachmark tours, not a custom dimmed-overlay reimplementation of the web coachmarks. |
| Login (v1 surface) | **Sign in with Apple + email + guest.** VK / Telegram sign-in **deferred** to a later delivery (the login screen extends trivially with more stacked buttons). Note: with no third-party *social* provider in v1, SIWA is technically optional (email is not a social login), but we ship it deliberately; once VK/TG land it becomes mandatory. |
| Wallet / store / ad banner | **Designed in v1** (full inventory), **delivery staged.** Keeps the region-aware payment seam + the out-of-region reject risk (§10) in scope from the start. |
---
## 3. Why "min iOS 18" does not freeze the design (reference)
Appearance is governed by the **SDK you build against** + whether you use **standard components**,
**not** by the deployment target. Building with the iOS 26 SDK means standard controls
(NavigationStack, toolbars, TabView, Button, List, sheets, alerts, Form) **automatically** adopt
Liquid Glass on iOS 26, and render the iOS 18 style on iOS 18 — with no per-version code. iOS 26-only
APIs (e.g. `.glassEffect` on custom views) are used behind `if #available(iOS 26, *)` with a fallback.
**Custom-drawn UI** (board, tiles, score) looks identical on every version because we own it — so the
minimum has almost no visual cost for the game's core surface.
---
## 4. Proposed layout under `apple/` (to refine at setup)
```
apple/
project.yml # XcodeGen spec (committed; the .xcodeproj is generated, gitignored)
App/ # thin app target: @main entry, Info.plist, entitlements, asset catalog, app icon, capabilities, signing
Packages/ # SPM modules — the real code (merge-friendly, testable; non-UI ones buildable on Linux)
Domain/ # game state, rules glue, scoring, turn model (platform-agnostic)
Engine/ # Swift port of the move generator/validator/scorer + DAWG reader (parity-pinned) — later
Wire/ # Connect-Swift + FlatBuffers client to the gateway (same wire as web/Android)
DesignSystem/ # tokens, typography (SF), board/tile visual spec, reusable components
Features/ # per-screen feature modules (views + @Observable models)
DataMocks/ # mock implementations of the data protocols (the "seam"); powers Previews + e2e stubs
Tests/ # or per-package tests: Swift Testing, snapshot, XCUITest
README.md
```
Workflow discipline (XcodeGen): the generated `.xcodeproj` is a **disposable artifact** — never edit
project structure in the Xcode UI expecting it to persist; change `project.yml` and regenerate. Files
are picked up by globs, not hand-listed. XcodeGen passes through arbitrary raw build settings and
custom build phases, so custom needs are expressible; worst case escape hatches are committing the
`.xcodeproj` or migrating to Tuist — no dead end.
---
## 5. Determining optimization principles
These five shape the architecture from the start (not "add later"):
1. **Push (APNs) instead of a live socket while backgrounded.** For a turn-based game we do **not** hold
a socket in the background; the opponent's move arrives via **push**. This is an energy-architecture
decision, not just notifications — it puts APNs on the critical path.
2. **Board rendered with `Canvas`**, not a tree of ~225 `View` nodes (far cheaper in perf and memory).
3. **Engine off the main thread** — move generation, DAWG traversal, scoring, hints run on a background
actor/Task; the UI stays responsive. Swift 6 concurrency enforces this.
4. **`mmap` the dictionary (DAWG)** — memory-map, do not load the whole thing into RAM; low footprint.
5. **Fast silent boot + spinner-less resume via cache.** Show UI instantly; move heavy init (dictionary
prep) off the launch path (lazy until first needed). On foreground, render the persisted state
immediately and **silently** re-establish the stream, reconciling deltas — no blocking overlay. (Same
spirit as the Android native "silent+fast boot" and the web "offline return-online poll".)
### Lifecycle / background (the mechanism behind #1 and #5)
`ScenePhase`: on `.background`/`.inactive` tear down the live stream, stop timers, release resources; on
`.active` resume from cache + silent reconnect. Respect **Low Data Mode / cellular**
(`NWPathMonitor.isConstrained/isExpensive`). Batch network to avoid keeping the radio awake (radio tail).
---
## 6. Hygiene practices (apply as we go)
- **Memory warnings** — purge caches; watch for retain cycles (`[weak self]` around streams/observation).
- **Kill any animation while backgrounded** (do not tick the turn clock in the background).
- **Low Power Mode** (`ProcessInfo.isLowPowerModeEnabled`) — trim animations/background activity.
- **Reduce Motion** (`accessibilityReduceMotion`) — drop lavish animations (accessibility **and** battery).
- **Thermal state** — reduce load under throttling.
- Let the screen sleep; no needless wake locks. No sensors (geo/motion) — none are needed.
---
## 7. Storage — persist eagerly, do not trust `willTerminate`
There is **no reliable destructive callback** on iOS: `willTerminate` is often **not** called (swipe
force-kill, memory kill happen silently). Relying on catching a "destroy" event is a trap.
Pattern: **persist on the way to background** (`scenePhase → .inactive/.background`); snapshot the state
that matters (in-progress tile placement on the board, an uncommitted move). For the uncommitted
placement, also persist **incrementally** on meaningful changes (a tile placed / removed), **coalesced**
(not on every drag pixel). Data volume is tiny, writes are cheap. Store: SwiftData or plain file/SQLite —
decide at implementation. Net effect: even a silent kill loses nothing; the last valid state is on disk.
---
## 8. Testing — take all three layers
The mock **seam** (protocol-backed data access with `DataMocks`) is both the Preview data source **and**
the e2e stub mechanism.
1. **Unit — Swift Testing** (`import Testing`, `@Test`, `#expect`): domain/logic, engine **parity golden
tests**, wire **codec** tests, view-models. Pure (non-UI) SPM packages are **Linux/CI-buildable** — a
slice of tests can run in the ordinary CI without a Mac.
2. **Snapshot — swift-snapshot-testing**: visual regression of SwiftUI views against reference images
(fixed simulator device for determinism). Valuable for a from-scratch design.
3. **e2e — XCUITest against the mock seam**: the native analogue of the web Playwright mock e2e. The app
launches with a launch argument that switches to `DataMocks`; the whole UI is driven deterministically
with no server. Runs on the Mac runner in CI.
Lesson carried from web: the mock e2e **bypasses the real network**, so wire/codec bugs are **not** caught
there — they need the **codec unit tests**.
---
## 9. Crash reporting & analytics
- **Crashes: MetricKit.** `MXMetricManager` delivers crash/hang/CPU/disk diagnostics to the app; we
**upload the payload to our own backend** and view it in the existing **Grafana/logs** contour — fully
self-hosted, zero third-party, no paid service. Apple's Xcode Organizer / App Store Connect also provides
aggregated crashes for TestFlight/release builds for free.
- **Operational requirement:** crashes are unreadable without **symbolication** → CI **must archive the
`dSYM`** for every build/release (artifact), else reports are raw addresses.
- **Analytics: none** (see §2). No tracking, no ATT prompt, no IDFA — cleaner privacy labels as a bonus.
---
## 10. Payments — region-aware seam, out-of-region reject risk (tracked)
- Target **both** external rails (Robokassa/analogs, primary for RU) and Apple IAP, behind a **region-aware
payment seam** (distribution region is set per-country in App Store Connect).
- **The RU situation is a genuine grey/in-flux area:** Apple has effectively disabled IAP for RU accounts
while external merchants are not yet blocked — hence real apps running both paths. We follow that.
- **[OPEN / TRACKED] Out-of-region payments:** external payment for digital goods is a **rejection risk
outside the RU region** (App Store guideline 3.1.1). We need a mechanism to route/avoid so we do not get
rejected. Re-verify current policy at the payments implementation phase. Do not die on this hill; the
owner's real-world observation stands, but the risk is on record.
- **[OPEN / TRACKED] Cross-platform spend compliance:** must not let web-funded (same-email) chips be
spent inside the iOS app (see §14 → I). A dedicated payments session will settle the iOS spend-
visibility. iOS v1 shows the packs but blocks Buy with a soft redirect to the web/Android build.
---
## 11. Gates
- **[GATE] Apple Developer account** (in process). Development in the **Simulator is unblocked without it**.
It blocks, downstream: running on a **physical device**, enabling **Sign in with Apple** (needs an App ID
+ capability), **push (APNs)**, **TestFlight / App Store release**. Sequence work so these land after the
account is active.
---
## 12. Audio — future (only if sounds are added)
Two common anti-patterns we must **not** ship, both fixed via `AVAudioSession`:
1. **Never silence the user's other audio.** Category **`.ambient`** (+ `.mixWithOthers`): mixes with the
user's music/podcast, does **not** interrupt it, and respects the **silent switch**. The typical mistake
is `.playback`, which stops others — we do not use it.
2. **Never "capture" audio at launch.** Activate the audio session **lazily — only while actually playing a
sound** — and do not hold it active otherwise; never activate on boot. Avoids the "launched and muted my
music while playing nothing" annoyance.
---
## 13. Development sequence (the process, agreed)
**Design the full online app up front; build in a vertical slice, then breadth.** Order:
1. **Screen & moving-parts inventory** — enumerate every screen and, per screen, its states, data, and
reactions to actions (re-imagined for iOS, informed by the web functional domains but not mirroring
them). *(Next up — to be filled in below as we do step 1.)*
2. **Navigation / state skeleton** — an app-level router; transitions expressed as state (`NavigationStack`
path, state-bound sheets), not bolted on afterward.
3. **One vertical slice first — the game screen** (with stubs), fully interactive. This is where the
**design system** (tokens, SF typography, board/tile visual spec) and the **reusable component kit** are
born — on the highest-value screen, not on a login form.
4. **Then breadth** — the remaining screens along the established patterns; each screen built with its
controls **and** mock data together (not screens-then-transitions-then-controls in horizontal passes —
SwiftUI is data-driven, so a screen and its controls co-evolve and transitions fall out of the state
model).
Throughout: mock is an **architectural seam** (protocol + `DataMocks`), not throwaway — the real
networking later slots in behind the same protocols. The **Swift engine port** is its own workstream
(stub the game during the presentation phase; wire the real parity-pinned engine later).
---
## 14. Inventory — screens & moving parts (step 1)
Seeded from `docs/FUNCTIONAL.md`, re-imagined for iOS-native idioms. Tags: **[keep]** as-is in
spirit, **[re-imagine]** with a native idiom, **[deferred]** designed but delivered later. The
per-screen **moving parts** (states / data / actions) are filled in section by section next, starting
with the game screen (the vertical slice).
### A. Boot / launch
- **Loading splash** [keep] — crossword tiles (ЭРУДИТ / ЗАГРУЗКА / ОЖИДАНИЕ), minimal, aligned with the
fast-silent-boot principle (no empty flash).
- **Offline first-launch → straight into the lobby as guest** [keep] (as Android).
- **"Couldn't load" + Retry** [re-imagine] native, no web-sign-in fallback.
- **"Client too old" update screen** [keep] native: Update (Store) / Play offline.
### B. Onboarding
- **Coachmark tours** (lobby series + game series) [re-imagine] via **TipKit**.
### C. Auth / identity (v1: SIWA + email + guest)
- **Login screen** [re-imagine] stacked buttons: **Sign in with Apple** (native sheet) + **Email** + **guest**.
VK / Telegram rows **[deferred]**.
- **Email code entry** [keep].
### D. Lobby
- **"My games" list** [keep] — three sections (your turn / opponent / finished; empty hidden), ordering
rules, unread dots (red/amber), status-blink, swipe-to-remove finished, server games greyed-out offline.
Swipe/kebab → native **swipe actions**.
- **Bottom tab bar** [re-imagine] → `TabView`: New Game / Statistics / Settings hub.
### E. New Game
- **New Game screen** [keep] — Quick game (AI / random), With friends (invite / pass-and-play), variant
pick (gated by preferences), RU "multiple words per turn" toggle, per-kind caps 🔒 prompts.
- **Friend invite** [keep] — pick from friend list.
- **Pass-and-play setup** [keep] — host PIN keypad, player rows (24), optional per-seat PIN.
### F. Game — VERTICAL SLICE (step 3)
- **Game board screen** [keep] — board on **`Canvas`**, rack, action bar (combined pass/exchange, hint,
shuffle, confirm), on-board legality preview + score badge, bag count.
- **Scoreboard header / seats** [keep] — opponent cards with add-friend 🤝 / block ✖️.
- **Move history** [keep].
- **Comms screen** [keep] — 💬 chat / 🔎 dictionary tabs.
- **Dictionary word-check** [keep] — complaint, external reference link (network-gated).
- **Hint** [keep] — lays suggested tiles; vs_ai 🔒 idle-gate + countdown (steady in-app timer).
- **Pass / exchange** [re-imagine] native sheet.
- **Pass-and-play in game** [keep] — seat PIN unlock, leader controls (skip / remove / end early).
- **Connection-lost banner + frozen play area** [keep].
- **End-of-game + export** [keep] — GCG + PNG via the native **share sheet**.
### G. Social / friends
- **Friends screen** (Settings → Friends) [keep] — list, one-time code (issue / redeem), requests,
block / unblock / unfriend; kebab → native **context menu**.
- **Incoming invitations** [keep] — accept / decline.
### H. Profile & settings
- **Settings hub** [re-imagine] grouped `List`: settings, profile, friends, wallet, about.
- **Settings** [keep] — language, theme, board bonus-label style, reduce-motion, zoom-the-board,
notifications-in-app-only, block toggles.
- **Profile** [keep] — display name, timezone, away window, variant preferences, sign-in methods
(link / unlink / merge), delete account; inline editing.
- **Merge confirmation** (irreversible) [keep].
- **Delete account** [keep] — mailed code / typed phrase.
### I. Wallet & store (designed in v1, delivery staged)
- **Wallet** [keep, deferred delivery] — balance (per-platform chips), Active line, Buy / Spend toggle,
watch-ad option, Exchange confirm, store-compliance warnings. Region/store-aware; ties to the §10
payment seam.
### J. Feedback
- **Feedback screen** [keep] — message + single attachment, reply display, feedback-banned state.
### K. Info / legal
- **About** [keep] — version, links.
- **Legal docs** (EULA / privacy / offer) [re-imagine] — in-app viewer or Safari, not web pages.
### L. Cross-cutting (not screens)
- **Advertising banner** under the nav (free players) [keep] — component, campaign rotation / colours / urgent.
- **Push (APNs)** + real-time in-app updates — data flows, not a screen (APNs is also the background
energy model, §5).
### Excluded (web / platform-specific — not in the native iOS app)
- **Admin console `/_gm`** — server-rendered web, not part of any client.
- **Landing page** — web-only.
- **Telegram / VK Mini App** hosting + their diagnostic screens — the iOS app is not a Mini App.
- **PWA install CTA** — web-only.
- **Promo bot**, **Telegram support chat** — Telegram-side (in-app Feedback in §J is kept).
### Native idioms replacing web mechanics
`TabView` (tab bar) · native swipe actions / context menus (swipe-remove, kebab) · sheets (popups/modals)
· `ShareLink` / share sheet (export) · **TipKit** (coachmarks) · grouped `List` (settings/legal) · SIWA
native sheet (auth).
### Assets, tiles & icons- **Tiles are drawn as runtime vectors** (a rounded rect + a font glyph + the point value, on the board
`Canvas`) — **no image assets, no pre-generation, no local-store cache.** Resolution-independent (crisp
under zoom-on-drop), theme/variant/locale-adaptive for free. Cache rendered tile images
(`ImageRenderer`) **only if** profiling later shows a need. The crossword loading splash **reuses the
same tile component** (zero assets).
- **LaunchScreen** is static, OS-level, a simple brand mark (background + logo/wordmark; system font or a
single logo image — **no tile assets**). Its duration **equals the real launch time** — it **cannot** be
extended or held programmatically (and fake delays are discouraged); the "longer" splash is the **in-app
crossword splash** (its timing is ours, shown until the lobby is ready). Match the LaunchScreen
background to the in-app splash so the OS→app handoff is seamless (no flash).
- **Icons = SF Symbols** (the system icon set) everywhere — all the emoji in this plan are placeholders
for meaning; the real UI uses SF Symbols (gear, chart.bar, dice, bubble.left, shuffle, lightbulb,
person.badge.plus, xmark/hand.raised, square.and.arrow.up, lock, …). **Custom vectors only where no
system symbol fits:** the three variant emblems and the tiles.
### Per-screen moving parts
#### F. Game screen — detail
**Layout**
- **iPhone: full board always visible + zoom-on-drop** (hybrid, web-parity, setting-gated by the
existing "Zoom the board" toggle). Board scaled to width; on tile drop it magnifies toward the drop
point for precision.
- **Bottom-anchored stack**, top→bottom: *(top strip — the ad banner (L) + connection chrome, else
empty)* → **seats header****board****rack****bottom toolbar**. Everything is packed to the
bottom; the top strip hosts the L banner / Connecting…/Offline/connection-lost chrome, else empty.
- **Orientation: iPhone portrait-only; iPad portrait + landscape.**
**Four regions**
1. **Scoreboard / header** — seats (name, score, whose-turn), unread dot (red msg / amber nudge),
connectivity state ("Connecting…" / Offline chip / connection-lost banner), 💬 entry to history/comms.
2. **Board (`Canvas`)** — 15×15 grid, placed tiles, staged tiles with on-board legality preview
(light green when a word forms, darker on run-through board tiles, pink when none; orange badge =
score), zoom-on-drop.
3. **Rack** — player tiles, drag/tap to place, shuffle, in-rack reorder.
4. **Action bar** — combined pass/exchange, hint (vs_ai 🔒 idle-gate + countdown), shuffle, confirm
(only when the move is legal), bag count.
**State axes (repaint the screen)**
- **Whose turn:** mine (preview + submit) vs opponent's (draft placement only, position-only).
- **Phase:** waiting for opponent (auto-match "searching for opponent"; resign/chat/nudge off) →
active → finished.
- **Connectivity:** online / connecting / offline / connection-lost-in-online-game (frozen).
- **Kind:** vs_ai (honest 🤖: unlimited wallet-free hints idle-gated, no chat/nudge/add-friend, no
clock — 7-day only, no export) · auto-match 2p (possibly a disguised robot) · friend 24 ·
pass-and-play (seats, PINs, leader).
- **Variant:** Erudite / RU Scrabble / EN Scrabble; RU "multiple words per turn" toggle changes the
preview (main word only vs all perpendiculars).
**Interactions**
- Place tile (drag or tap; the game infers direction) · **blank ('?') letter picker** · recall a
staged tile to the rack · legality preview on-device (first-time warm-up; server fallback) · confirm
(only when legal) · resign · shuffle · **hint** (per-game allowance → wallet; vs_ai unlimited but
idle-gated 30 min, 🔒 + steady in-app countdown) · zoom-on-drop (setting) · **per-game composition
persistence** (rack arrangement + uncommitted move restored, cross-device).
**Sub-surfaces (from the header)**
- **Seat cards** (with history open): add-friend 🤝 / block ✖️, confirm on a fading ✅; disabled/
disappear rules.
- **Move history** (words, coordinates, scores; pass/exchange/resign/timeout notes; closing ± endgame
settlement).
- **Comms** — chat (1 msg/turn, ≤60 chars, no links) + dictionary word-check (complaint, external
reference link); **nudge**.
- **Pass-and-play** — seat PIN unlock (keypad replaces the locked seat's rack); leader button →
skip / remove / end-early (leader password); hints and chat off.
- **End-of-game** — result, final scores (yours first), export 📤 (GCG + PNG, never for honest-AI);
"could not be continued → draw" organizer note.
- **Connection-lost (online game)** — slim banner, play area freezes (rack + move controls disable;
resign/add-friend/block/chat hidden; a started move stays as a draft); if already in chat/dictionary
you stay (chat read-only, dictionary keeps checking on-device).
**UX decisions**
- **Comms sheet:** a **single detented sheet** with a segmented control **[History | Chat |
Dictionary]**; the board + seats header stay visible above; native swipe-to-dismiss + grabber.
**Opened by tapping the seats header** (where the unread dot / 💬 live). iPad landscape later adapts
it to a side column. (Simplifies the web's nested history → comms navigation.) The **export 📤**
lives inside this sheet (History segment, finished game) — **not** in the header.
- **Bottom toolbar (not a TabBar).** The in-game controls are actions, so they sit in a native bottom
toolbar (`ToolbarItem(.bottomBar)`, the Safari/Mail pattern), **not** the app `TabView` — which is
navigation and is **hidden inside the game**. Layout: **`[pass/exchange] — [hint ⇄ confirm] —
[shuffle]`** (3 slots). The centre control **morphs hint → confirm** only once a legal move is
staged (doubles as "you can commit" feedback). **Exchange** is inside the `pass/exchange` control
(select tiles = exchange, none = pass). **Recall** a staged tile by tapping it — no dedicated button.
- **Tile placement:** **both drag and tap-to-place**; recall by tap; the **blank ('?') letter picker**
is a popover over the variant's alphabet.
- **Seat cards:** composed from primitives (`HStack`/`VStack` + design tokens, optional `GroupBox`),
no prebuilt control. Base header shows name / score / whose-turn / unread dot only.
- **Opponent social actions:** **tap a seat card → a mini player panel** (popover on iPad, sheet on
iPhone) showing the relationship status (pending / friends / blocked) with **Add friend** / **Block**
(`Button(role: .destructive)` + native `confirmationDialog`). Replaces the web's in-place score-card
swap.
- **Rack:** shuffle button; reorder tiles by in-rack drag; tap selects a tile for tap-place; freed
slots render empty.
- **Waiting for opponent (auto-match):** the opponent card reads "searching for opponent" with a calm
activity indicator (not a blocking spinner); resign/chat/nudge hidden; you may arrange tiles if it is
your turn; a "you can close the app and come back" note.
- **vs_ai hint-lock:** the hint control carries a 🔒 badge while idle-gated; a tap shows a transient
"unlocks in N min" popover; the lock lifts live at the mark.
- **End-of-game:** a result plate on the board (win/lose/draw, final scores yours-first); export via the
sheet (above); never for honest-AI.
- **Connection-lost / offline chrome** (per FUNCTIONAL, native styling): offline = blue header + Offline
chip; brief blip = quiet "Connecting…" in the header; connection-lost in an online game = slim banner
+ frozen play area (rack/controls disabled, resign/social/chat hidden, started move kept as a draft).
*(F inventory + UX skeleton complete. Design-system specifics — colours, typography, tile visuals —
emerge while implementing the vertical slice, step 3.)*
#### D. Lobby — detail
**UX decisions**
- **List style:** native **inset-grouped `List`** (the Settings-app look — floating rounded section
cards over a grouped background). Sections: **your turn / opponent's turn / finished** (empty hidden).
- **Grouping (option A — unified lobby):** one lobby, all games in the three state sections. **Local
games** (offline vs_ai + pass-and-play, which never sync) are **badged inline** with an "on-device"
marker. When offline, **server rows grey out in place** (un-openable; a tap explains why) — not moved
or hidden.
- **Fold/unfold:** **opponent + finished** sections are collapsible (`DisclosureGroup`); **"your turn"
is always expanded** (it is the action section). Fold state is remembered per-device.
- **Bottom `TabView` (app navigation):** New Game / Statistics / Settings hub. (Confirmed: the TabBar
switches screens; in-game controls are a bottom toolbar, not this.)
- **Row anatomy:** opponent name (or "searching for opponent" for an unfilled auto-match) · a **variant
emblem** (three distinct icons — Erudit / RU Scrabble / EN Scrabble; **not** a national flag, which
can't separate the two Russian variants) · unread dot (red msg / amber nudge) · an **on-device badge**
for local games · a **result** for finished games. **No status icon** (the sections already group by
status). No avatars (name only). Compact, line-separated. *(Exact visual TBD on the slice.)*
**Behaviour (per FUNCTIONAL, kept)**
- **Ordering:** your-turn first (longest-waiting on top); opponent + finished most-recent first; a game
with any unread floats to the top within your-turn/opponent (finished keeps its order).
- **Live updates:** a listed game that becomes your-turn or finishes while the lobby is open
**flashes its row briefly** (the status-icon blink is retargeted to a row-level flash, since there is
no status icon); the game also moving into the *your turn* section is itself a signal. An opponent's-
turn change is silent, applied in place.
- **Unread dot:** next to the game — **red** when an unread message waits, **amber** when only nudges.
- **Remove finished:** native **swipe action** → ❌ (per-account, permanent, no undo). An AI game you
left (resign or 7-day lapse) auto-drops from *finished*; a normally-finished AI game stays until
removed; no other type auto-removes.
- **Cold open:** loading splash (crossword tiles ЭРУДИТ/ЗАГРУЗКА/ОЖИДАНИЕ) until the list is ready — no
empty flash.
- **Offline chrome:** blue header + Offline chip; Stats tab disabled; invitations hidden; server rows
greyed.
#### E. New Game — detail
**UX decisions**
- **Structure:** a top **segmented `[Quick game | With friends]`** + a contextual **inset-grouped form**
below (consistent with the lobby aesthetic).
- *Quick game:* opponent segmented **`[AI | Random]`** → variant (emblem chips) → RU "multiple words"
toggle → **Start**.
- *With friends:* rows **Invite a friend** / **Pass-and-play** → pushed sub-flows.
- **Variant picker:** **emblem chips / segmented** (≤3 variants); **hidden entirely when a single variant
is enabled** (the common default — Erudite only).
**Behaviour (per FUNCTIONAL, kept)**
- **Quick game:** AI default (instant 🤖, no wait) / Random (auto-match 2p — drops you into the game to
wait inside; "you can close the app and come back" note).
- **With friends offline → only pass-and-play** (invite needs the network).
- **RU "multiple words per turn":** default **off**; RU games only; English is always standard, no toggle.
- **Per-kind caps** (AI / random / friend): a start at its cap shows **🔒**; a tap opens a prompt — a
**guest** is invited to sign in / create an account, a **signed-in** player is told to finish a current
game. **Accepting an incoming invitation is never capped.**
- **Friend invite:** pick **24** from the friend list; the inviter sets the game settings; starts once
**all accept**; any decline cancels; an unanswered invite expires in **7 days**; shareable as a Telegram
deep link (on VK the link only opens the app, the code is entered by hand).
- **Pass-and-play setup:** host PIN keypad → "are you playing too?" (yes → first seat takes your name) →
name players 24 (add/remove; a removal asks the leader password) → optional per-seat PIN.
- **Per-game settings set** (inviter chooses for friend games; quick games use defaults): variant · RU
multiple-words · **move timeout** (5 min24 h, default 24 h) · **hints** (allowed? how many each) ·
**leaver's-tile disposition** (returned to bag / removed — 34-player games only).
**UX decisions (sub-details)**
- **Cap 🔒 prompt:** a 🔒 badge on the **Start** control (looks unavailable); a tap opens a native
alert — guest: a "Sign in / Create account" action; signed-in: an informational "finish a current
game first."
- **Pass-and-play setup** (pushed, stepped): (a) 4-digit host PIN on a custom lock-screen-style keypad
(no built-in iOS control — composed); (b) "are you playing too?"; (c) player rows as an editable
`List` (EditMode add/remove; a removal is gated by the leader password); (d) per-seat PIN via a small
lock toggle in the row.
- **Friend-invite** (pushed): multi-select from the friends `List` (checkmarks, 24) → a **settings
step** reusing the per-game settings set → **Send** (+ shareable link). Setting controls: move
timeout = native picker (5 min24 h); hints = toggle + stepper; leaver disposition = segmented
`[to bag | out of play]`.
#### C. Login / identity — detail (v1: SIWA + email + guest)
**UX decisions**
- **No hard login wall — guest by default** (offline-first, as Android native). First launch drops
straight into the lobby as a guest.
- **Reusable sign-in sheet:** a bottom **detented sheet** (same idiom as the comms sheet) with the
sign-in buttons in a **vertical stack****Sign in with Apple** (`SignInWithAppleButton`) + **Email**
(future VK/TG rows append here). **Shown once on first launch, dismissible** — dismiss = stays guest.
**Reused on demand** from the Profile "Sign in" action.
- **Email flow:** address → **6-digit code** entry → signed in / linked; rate-limit feedback (cooldown +
hourly cap). **Code-only on native** — the magic link is unused (it opens an external browser that
can't return to the app; mirrors the installed-PWA case in FUNCTIONAL).
- **SIWA:** native system sheet → gateway validates → create / link the account.
- **Merge on collision** (the identity already belongs to another account): guest initiator = seamless;
durable initiator = irreversible confirmation. Triggered by sign-in; the merge screen itself lives in
**H** (Accounts, linking & merge).
#### B. Onboarding — detail (TipKit, native)
**UX decisions**
- **TipKit, native flavour** (confirmed): contextual tips anchored to controls, ordered via **`TipGroup`**
**no full-screen dimming, no tap-anywhere-advance**; each tip dismisses itself (its ✕ or performing
the action). Shown once; per-device eligibility.
- **Two tours:** **lobby series** (⚙️ settings, ✏️ statistics, 🎲 new-game tabs) + **game series**
(scoreboard header, pass/exchange, hint, shuffle, rack). Localized (en/ru).
- **First-launch sequence:** lobby (guest) → sign-in sheet (C) → on dismiss → the TipKit lobby tour
(so the sheet and the tips don't overlap).
#### G. Friends — detail (Settings → Friends)
**UX decisions**
- **Structure:** a single **inset-grouped `List`** with sections **Requests** (incoming: accept /
decline; outgoing: cancel) · **Friends** · **Blocked** (unblock). A toolbar **"+"** opens the add
sheet.
- **Add sheet ("+"):** **Enter a code** (6-digit redeem, valid 12 h) / **Share my code** (generate +
`ShareLink`).
- **Friend-row actions:** **swipe actions****Remove** (destructive) + **Block** — each gated by a
naming **`confirmationDialog`** ("Remove from friends?" / "Block this player?").
- Chat / nudge are **not** here — they live in the in-game comms sheet (F). Global block toggles
(incoming chat / friend requests) live in **Profile (H)**.
**Behaviour (per FUNCTIONAL, kept)**
- Two ways to friend: redeem a one-time code, or a request to someone you've played with
(accept / ignore→30-day lapse / decline→blocks until they hand you a code). Cancel your outgoing;
unfriend removes it.
- A per-user block is one-directional and silent; **unblock and unfriend live only here**.
#### H. Profile & settings — detail
**UX decisions**
- **Settings hub** = an icon **`List` of drill-down `NavigationLink` rows** (the Podcasts → Library
pattern), SF-symbol icons, with room left at the bottom for optional general info later. Rows:
- **Interface** (⚙️) → display-settings screen.
- **Friends** (person.2) → G.
- **Wallet** (creditcard) → I. **Hidden for guests.**
- **Info** (info.circle) → About + version + legal (K).
- **Feedback** → J. **Hidden for guests.** Unread-reply **badge** on the Settings tab **and** on this
row.
- **Profile = a top-right nav-bar icon** (`ToolbarItem(.topBarTrailing)`, `person.circle`; filled /
initial when signed in) on the settings hub: **guest → the sign-in sheet (C); signed-in → the profile
screen.** (Duplicated onto the lobby only if asked later.)
- **Interface screen** (display prefs): language · theme · board bonus-label style · reduce-motion ·
zoom-the-board.
**Guest has no editable settings** — the random nick is not changeable, the variant is always
`erudite_ru`, the timezone is auto-detected (never set by hand). So there is **no guest profile
screen**: the profile icon for a guest opens the **sign-in sheet** (confirms C). It also confirms E's
"variant picker hidden when a single variant is enabled" for guests.
**Profile screen (signed-in / durable)** — an inset-grouped `Form`:
- **Name** — inline `TextField` + validation (letters + `·`/`.`/`_` separator, optional trailing `.`
or ≤5 digits, ≤32 chars, ≤5 specials).
- **Play** — timezone (UTC-offset picker, pre-filled from the device) · away window (10-min grid, ≤12 h,
wraps midnight; a two-time range) · variant preferences (multi-select emblems, ≥1 kept) ·
notifications-in-app-only (`Switch`, default on).
- **Privacy** — global block toggles (incoming chat / friend requests).
- **Sign-in methods** — rows link / unlink; **email is "changed", not unlinked**; the **last** method
can't be unlinked; a provider that belongs to another account offers the irreversible **merge** (guest
initiator seamless, durable initiator confirmed).
- **Delete account** — destructive footer; confirm via a mailed code (email account) or a typed phrase.
Legal-retention removal (not erasure), per FUNCTIONAL.
#### I. Wallet & store — detail (durable only; delivery staged)
**UX decisions**
- **Structure:** inset-grouped — a **balance card** (running-context «Фишка» first, then linked
other-platform chips behind their logos: VK / Telegram / web / **Apple** as a new context) → **Active**
line (hints remaining + ad-free end date / "forever"; hidden when empty) → segmented **`[Buy | Spend]`**
→ rows. **No purchase history.**
- **Buy (v1):** the chip packs are **shown** (the user sees purchases exist in the game), but tapping
**Buy** shows a light message — *"Purchases are unavailable on the Apple platform. You might like
another version of [our app](landing)."* — a soft redirect to the Android/PWA build while payments are
not wired. Chosen over hiding so the offering stays visible; unlocking real Buy later (IAP / external
rails) is then just enabling the path.
- **Spend:** values (extra hints, days without ads) at a fixed chip price; **Exchange** action + a
confirm `confirmationDialog`.
**[OPEN / TRACKED — a dedicated payments session, not resolved here]**
- **Cross-platform spend compliance (Apple landmine):** we must **not** let a user buy chips on the web
(email login) and then **spend** them inside the iOS app where the same email is signed in — spending
externally-funded digital currency in-app violates App Store rules. This reshapes the iOS
**store-compliance spend-visibility** (what of the balance is spendable on iOS, given there is no
Apple-funded chip yet). The owner will settle it in a dedicated payments session. Ties to §10.
#### J. Feedback — detail (durable only; guests hidden)
**UX decisions**
- inset-grouped `Form`: **`TextEditor`** (≤1024, live counter) → attachment row → **Send**.
- **Attachment:** one file ≤1 MB (images / PDF / text-log / office / RTF / archives). **"Attach"** →
menu **`PhotosPicker` (Photos)** / **`fileImporter` (Files)**; **no camera**. An unsupported file is
refused **without naming** the allowed types; the chosen file shows as a removable chip.
- **After send:** the form clears, "Ваше сообщение отправлено"; while the operator has not dealt with it,
a locked state "Ожидаем рассмотрения вашего последнего обращения" (Send disabled).
- **Reply** below: "Ответ на ваше последнее сообщение"; links open in `SFSafariViewController`; marked
read on view; gone after a week.
- **Unread-reply badge:** Settings tab + Feedback row (H).
- **Feedback-barred** player: Send disabled.
#### K. Info / legal — detail
**UX decisions**
- **About screen** (Info hub row): app name · **version** (native build + the client-version) · links to
the three legal docs · copyright; optional "Rate on the App Store".
- **Legal docs** (EULA `/eula/`, privacy `/privacy/`, offer `/offer/` + live price list): open via
**`SFSafariViewController` to the hosted URLs** — always current, the offer's price list stays live,
bilingual handled by the page; no render pipeline duplicated in the app. **Offline:** unavailable
("недоступно офлайн").
#### A. Boot / launch & update — detail
**UX decisions**
- **LaunchScreen:** static, OS-level, instant (**iOS cannot animate the launch screen**) — brand/logo.
- **In-app loading:** the crossword-tiles splash (ЭРУДИТ/ЗАГРУЗКА/ОЖИДАНИЕ) shows **only while the lobby
list isn't ready**; fast silent boot → usually straight to the lobby. Offline cold launch → straight to
the offline lobby.
- **"Couldn't load" + Retry:** a native screen when an online launch can't reach the backend (a quiet
retry first).
- **Client too old** (only on an online action, never while playing offline): full-screen, non-dismissable
**Update** (App Store listing) + **Play offline** (keep on-device games).
- **"Update available" (soft, not required):** a **transient top banner styled like a native
notification** — "Пора обновить приложение", **tap → App Store**, **auto-dismiss after 5 s**, shown on
**every launch when online**. (iOS has no first-party toast → a custom notification-style banner;
deviates by choice from the web's persistent dismissible bar.)
#### L. Advertising banner — detail (cross-cutting component)
**UX decisions**
- **Placement:** a one-line strip **on the main tabs (Lobby / New Game / Statistics) AND in-game (F)**
players spend most time in-game. Hidden in settings drill-downs and modal flows. **In-game it occupies
the top strip** (the empty space above the seats header), coexisting with the connection chrome
(Connecting… / Offline / connection-lost banner sit with it).
- **Audience:** free players only (not lifetime-paid, no purchased hints, no `no_banner` role); guests
see it; appears/disappears **in place** on a property change.
- **Language (native, no bot channel):** the **interface language** (RU default) — there is no "bot you
play through" here.
- **Not hidden under TipKit** (no dimming overlay, unlike the web coachmarks).
**Behaviour (per FUNCTIONAL, kept)**
- Operator campaigns (weight %, optional window); compete in proportion to weight; a **house/default**
campaign fills the unsold share; an **urgent** campaign shows to everyone and is the only thing shown.
- Fair weighted rotation; a multi-message campaign cycles in turn; transitions (fade-out → gap →
fade-in); long-message scroll; timings from the server (`/_gm/banners`).
- Per-non-default-campaign colours (theme-aware set + optional dark override; derived border); urgent flag.
+7 -2
View File
@@ -49,9 +49,14 @@ COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console
# can write new version subdirectories at runtime. The volume preserves uploaded
# versions across deploys and, once seeded, is not re-seeded — so after bootstrap
# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5).
# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
# A second, UNSHADOWED copy of the build's DAWGs: the /opt/dawg volume mount hides the
# image's copy there, so this is where engine.DeliverVersion reads the build version from
# to add it (add-only) to the volume on boot — the deploy-delivers path that lets a bumped
# BACKEND_DICT_VERSION go live for new games without an admin upload (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg-seed
ENV BACKEND_DICT_DIR=/opt/dawg
ENV BACKEND_DICT_SEED_DIR=/opt/dawg-seed
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
ENTRYPOINT ["/usr/local/bin/backend"]
+11 -4
View File
@@ -111,6 +111,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("database migrations applied")
// Deliver the build's dictionary version onto the persistent volume if a redeploy
// bumped it (add-only; old versions in-flight games pin are untouched), so the new
// dictionary is resident and can be activated below without an admin upload.
if err := engine.DeliverVersion(cfg.Game.DictDir, cfg.Game.DictSeedDir, cfg.Game.DictVersion); err != nil {
return fmt.Errorf("deliver dictionary version: %w", err)
}
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
if err != nil {
return fmt.Errorf("load dictionaries: %w", err)
@@ -118,6 +124,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
defer func() { _ = registry.Close() }()
logger.Info("dictionaries loaded",
zap.String("dir", cfg.Game.DictDir),
zap.String("seed_dir", cfg.Game.DictSeedDir),
zap.String("version", cfg.Game.DictVersion))
// Admin console: an optional backend client to the Telegram connector
@@ -147,10 +154,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
accounts := account.NewStore(db)
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
// Reconcile the persisted active dictionary version with the registry: a
// version activated through the admin console (and written to the dictionary
// volume) is adopted again after a restart; otherwise the configured seed
// version is kept and persisted (docs/ARCHITECTURE.md §5).
// Reconcile the active dictionary version with the registry: the build version
// (delivered above) becomes active so a redeploy that bumped it goes live for new
// games, except a newer version installed out-of-band through the admin console,
// which is not downgraded by a restart (docs/ARCHITECTURE.md §5).
if err := games.InitActiveVersion(ctx); err != nil {
return fmt.Errorf("init active dictionary version: %w", err)
}
+19 -5
View File
@@ -10,6 +10,7 @@ import (
"database/sql"
"errors"
"fmt"
"math/rand/v2"
"strings"
"time"
@@ -525,8 +526,13 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
return created, nil
}
// guestDisplayName is the display name stamped on a freshly provisioned guest.
const guestDisplayName = "Guest"
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a
// random match the other player sees a distinct name rather than every guest reading a bare
// "Guest". Not an identifier and not unique (collisions are harmless — it is a label only).
func guestDisplayName() string {
return fmt.Sprintf("Guest%06d", rand.IntN(1_000_000))
}
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both
@@ -534,7 +540,7 @@ const guestDisplayName = "Guest"
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err)
@@ -543,9 +549,17 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
if tz == "" {
tz = "UTC"
}
// Seed the interface language from the client's detected locale (validated to a supported
// one), so a fresh guest's language-dependent server content — the ad banner, bot messages —
// is right from first contact rather than the 'en' column default until the client's later
// language reconcile catches up. An unsupported or absent code keeps the 'en' default.
lang := supportedLanguage(language)
if lang == "" {
lang = "en"
}
stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true, tz).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
VALUES(accountID, guestDisplayName(), true, tz, lang).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
+1
View File
@@ -106,6 +106,7 @@ func Load() (Config, error) {
gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
return Config{}, err
}
+92
View File
@@ -0,0 +1,92 @@
package engine
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// DeliverVersion makes the build's dictionary version resident on the persistent
// dictionary volume without disturbing the versions in-flight games already pin.
//
// The volume is seeded from the image once and never re-seeded, and the image's
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
// copies it into dictDir/<version>/ when the version is not already present —
// neither the flat seed's own recorded label nor an existing version
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
// uploads are never touched, so old games keep the version they pin while the new
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
func DeliverVersion(dictDir, seedDir, version string) error {
if seedDir == "" || version == "" {
return nil
}
target := filepath.Join(dictDir, version)
if _, err := os.Stat(target); err == nil {
return nil // already delivered on a prior boot
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
}
// A fresh volume is populated from the image and recorded as this version by the
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
// records the label on first use, matching OpenWithVersions.
seed, err := resolveSeedVersion(dictDir, version)
if err != nil {
return err
}
if seed == version {
return nil
}
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
if err := os.RemoveAll(staging); err != nil {
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
}
if err := os.MkdirAll(staging, 0o755); err != nil {
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
}
for _, file := range dictFiles {
src := filepath.Join(seedDir, file)
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
continue // a variant absent from the seed is simply absent under this version
} else if err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
}
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
_ = os.RemoveAll(staging)
return err
}
}
if err := os.Rename(staging, target); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
}
return nil
}
// copyFile copies a single file, truncating the destination.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("engine: open %s: %w", src, err)
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("engine: create %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("engine: finalise %s: %w", dst, err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestDeliverVersion(t *testing.T) {
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
t.Fatalf("blank seedDir: %v", err)
}
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
dict := t.TempDir()
seed := t.TempDir()
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, f := range dictFiles {
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
t.Fatal(err)
}
}
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("deliver v1.3.1: %v", err)
}
for _, f := range dictFiles {
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
if err != nil {
t.Fatalf("delivered %s: %v", f, err)
}
if string(got) != "dawg:"+f {
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
}
}
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
}
// Idempotent: a second call is a no-op and leaves no staging directory behind.
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("re-deliver v1.3.1: %v", err)
}
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
t.Errorf("a staging directory survived delivery")
}
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
// as a redundant subdirectory.
fresh := t.TempDir()
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
t.Fatalf("fresh deliver: %v", err)
}
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
}
}
func hasStaging(entries []os.DirEntry) bool {
for _, e := range entries {
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
return true
}
}
return false
}
+5 -5
View File
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion.
//
// So bumping the build seed on a live volume is a harmless no-op (it only takes
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// which would void games pinned to the prior label and mis-serve new ones. New games
// still pin the active version (DB-persisted, set by the admin console), which is the
// real way a running contour moves to a new release.
// So a later build seed never relabels the already-seeded flat bytes — which would void
// games pinned to the prior label and mis-serve new ones. The new build version is instead
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion),
// so a redeploy still moves new games to it while old games keep the flat label. New games
// pin the active version (DB-persisted); a console upload can also set it out-of-band.
//
// A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is
+7
View File
@@ -18,6 +18,13 @@ type Config struct {
// DictVersion labels the dictionary version new games pin. Sourced from
// BACKEND_DICT_VERSION.
DictVersion string
// DictSeedDir holds the image's read-only copy of the build's DictVersion DAWGs,
// on a path the persistent DictDir volume does NOT shadow. On boot the backend
// delivers this version into DictDir/<DictVersion>/ if absent (add-only), so a
// redeploy that bumped DICT_VERSION makes the new dictionary resident and activatable
// without disturbing the versions in-flight games already pin. Sourced from
// BACKEND_DICT_SEED_DIR; empty disables delivery (the pre-existing seed-only behaviour).
DictSeedDir string
// TimeoutSweepInterval is how often the sweeper scans for overdue turns.
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
TimeoutSweepInterval time.Duration
+19 -9
View File
@@ -232,21 +232,31 @@ func (svc *Service) ActiveVersion() string {
}
// InitActiveVersion reconciles the persisted active dictionary version with the
// registry at startup. If a version was persisted and is resident, it becomes the
// active version; otherwise the configured seed version is kept and persisted as
// the initial active version. Call once during wiring, before serving traffic.
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build
// version thus activates it for new games without any admin step, while old games keep
// the version they pin. The one exception is a version installed out-of-band through
// the admin console that is NEWER than the build version and still resident — it is
// kept, so a restart on an older image never downgrades a hotfix. A build version that
// is not resident (delivery disabled or a partial state) falls back to a resident
// persisted version. The chosen version is persisted so the admin console reflects it.
// Call once during wiring, before serving traffic.
func (svc *Service) InitActiveVersion(ctx context.Context) error {
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
if err != nil {
return err
}
if ok && svc.versionResident(persisted) {
svc.verMu.Lock()
svc.version = persisted
svc.verMu.Unlock()
return nil
target := svc.activeVersion() // the build seed version (config.DictVersion)
if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
target = persisted // a newer out-of-band install wins over the build version
}
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
target = persisted // the build version is unloadable — keep a resident one
}
svc.verMu.Lock()
svc.version = target
svc.verMu.Unlock()
return svc.store.SetActiveDictVersion(ctx, target)
}
// SetActiveVersion records version as the active dictionary version, persisting it
+45
View File
@@ -0,0 +1,45 @@
package game
import (
"strconv"
"strings"
)
// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH"
// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels
// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed
// or malformed pair never crashes the boot-time active-version reconcile).
func compareDictVersions(a, b string) int {
pa, oka := parseDictVersion(a)
pb, okb := parseDictVersion(b)
if !oka || !okb {
return strings.Compare(a, b)
}
for i := range pa {
if pa[i] != pb[i] {
if pa[i] < pb[i] {
return -1
}
return 1
}
}
return 0
}
// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting
// whether it matched that shape.
func parseDictVersion(v string) ([3]int, bool) {
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
+37
View File
@@ -0,0 +1,37 @@
package game
import "testing"
func TestCompareDictVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"v1.3.0", "v1.3.1", -1},
{"v1.3.1", "v1.3.0", 1},
{"v1.3.1", "v1.3.1", 0},
{"v1.3.9", "v1.3.10", -1}, // numeric, not lexical
{"v1.10.0", "v1.9.0", 1},
{"v2.0.0", "v1.9.9", 1},
{"1.3.1", "v1.3.1", 0}, // the leading v is optional
// Non-semver falls back to a byte comparison, keeping the ordering total.
{"weird", "weird", 0},
{"a", "b", -1},
}
for _, c := range cases {
if got := compareDictVersions(c.a, c.b); sign(got) != c.want {
t.Errorf("compareDictVersions(%q, %q) = %d, want sign %d", c.a, c.b, got, c.want)
}
}
}
func sign(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
default:
return 0
}
}
+23 -3
View File
@@ -5,6 +5,7 @@ package inttest
import (
"context"
"errors"
"regexp"
"testing"
"time"
@@ -229,21 +230,40 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
}
// A guest is seeded its detected offset; an empty one keeps the UTC default.
guest, err := store.ProvisionGuest(ctx, "-05:30")
// A guest is seeded its detected offset and interface language; an empty offset keeps the
// UTC default and an empty/unsupported language keeps the 'en' default.
guest, err := store.ProvisionGuest(ctx, "-05:30", "ru")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
}
plainGuest, err := store.ProvisionGuest(ctx, "")
if guest.PreferredLanguage != "ru" {
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
}
// A guest's display name is "Guest" + a random six-digit suffix (distinct to opponents).
if m, _ := regexp.MatchString(`^Guest\d{6}$`, guest.DisplayName); !m {
t.Errorf("guest DisplayName = %q, want Guest + 6 digits", guest.DisplayName)
}
plainGuest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision plain guest: %v", err)
}
if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
}
if plainGuest.PreferredLanguage != "en" {
t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage)
}
// An unsupported locale falls back to the 'en' default rather than persisting a junk value.
frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR")
if err != nil {
t.Fatalf("provision fr guest: %v", err)
}
if frGuest.PreferredLanguage != "en" {
t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage)
}
}
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
+1 -1
View File
@@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) {
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
guest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -103,14 +103,31 @@ func TestDictionaryUpdateFlow(t *testing.T) {
t.Errorf("installed dawg missing on disk: %v", err)
}
// The choice survives a restart: a fresh service over the same DB and registry
// re-adopts the persisted active version.
// The choice survives a restart on the SAME build: an out-of-band admin install newer
// than the build version is not downgraded (a restart on an older image keeps the hotfix).
restarted := newGameServiceOn(dir, "v1.0.0", reg)
if err := restarted.InitActiveVersion(ctx); err != nil {
t.Fatalf("restart init: %v", err)
}
if got := restarted.ActiveVersion(); got != "v9.9.9" {
t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got)
}
// A redeploy that delivered a build version NEWER than the out-of-band one activates it
// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
t.Fatalf("make v10.0.0 resident: %v", err)
}
deployed := newGameServiceOn(dir, "v10.0.0", reg)
if err := deployed.InitActiveVersion(ctx); err != nil {
t.Fatalf("deploy init: %v", err)
}
if got := deployed.ActiveVersion(); got != "v10.0.0" {
t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
}
// Restore the out-of-band active version so the assertions below still read v9.9.9.
if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
t.Fatalf("restore active: %v", err)
}
// New games pin the new version; the in-progress game keeps its own, still resident.
+1 -1
View File
@@ -431,7 +431,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
guest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+1 -1
View File
@@ -154,7 +154,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+39 -9
View File
@@ -347,8 +347,10 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
}
}
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
// that owns the email: the durable account wins and a fresh session is minted.
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
// the durable account as if it simply logged in).
func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -364,17 +366,18 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
merge, err := links.MergeEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
if !confirm.Merged || confirm.MergeRequired {
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
}
if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
if confirm.Merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
}
if merge.SwitchedToken == "" {
if confirm.Merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
@@ -385,6 +388,33 @@ func TestAccountLinkGuestInversion(t *testing.T) {
}
}
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "conflict-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
}
if mergedInto(t, guest) != uuid.Nil {
t.Error("a refused auto-merge must not tombstone the guest")
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
+1 -1
View File
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx, "")
guest, err := st.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+32 -6
View File
@@ -31,13 +31,39 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
}
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
// required (the caller renders an irreversible-merge confirmation from it).
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked,
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
// is required (the caller renders an irreversible-merge confirmation from it). Merged is
// set when the caller was a guest, so the merge ran inline (see confirmOrAutoMerge) and
// Merge carries its result (the switched-session token for the surviving durable account).
type ConfirmResult struct {
Linked bool
MergeRequired bool
SecondaryID uuid.UUID
Merged bool
Merge MergeResult
}
// confirmOrAutoMerge decides a required merge at the confirm step. A durable caller gets
// the explicit MergeRequired confirmation (consolidating two real accounts is irreversible
// and consequential — the user must see it). A GUEST caller does not: by the guest-primary
// rule the durable other account survives and the ephemeral guest is retired, folding its
// games/wallet/stats in — from the user's side it is simply "logged into my account", so the
// merge runs inline and the client just adopts the switched session. The active-game guard can
// still refuse (accountmerge.ErrActiveGameConflict), surfaced to the caller unchanged.
func (s *Service) confirmOrAutoMerge(ctx context.Context, callerID, owner uuid.UUID) (ConfirmResult, error) {
caller, err := s.accounts.GetByID(ctx, callerID)
if err != nil {
return ConfirmResult{}, err
}
if !caller.IsGuest {
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
res, err := s.merge(ctx, callerID, owner)
if err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Merged: true, Merge: res}, nil
}
// MergeResult reports a completed merge. PrimaryID is the surviving account.
@@ -67,7 +93,7 @@ func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email,
}
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, accountID, owner)
}
// MergeEmail re-verifies the code and merges the address's account into the
@@ -103,7 +129,7 @@ func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, exter
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeTelegram merges the account owning a gateway-validated Telegram identity
@@ -150,7 +176,7 @@ func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
+8 -4
View File
@@ -160,16 +160,20 @@ type guestAuthRequest struct {
// Subtype is the client-reported device family (ios/android/web) of this direct
// (web/native) session; there is no external signer, so it is recorded best-effort.
Subtype string `json:"subtype"`
// Language is the client's detected interface locale, seeding the fresh guest's
// interface language (best-effort; an unsupported/absent value keeps the 'en' default).
Language string `json:"language"`
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
// seeding its time zone from the optional detected browser offset.
// seeding its time zone from the optional detected browser offset and its interface
// language from the optional detected locale.
func (s *Server) handleGuestAuth(c *gin.Context) {
// The body is optional: an absent or malformed one simply yields no time-zone seed
// (the account keeps the UTC default), so a bind error must not fail the bootstrap.
// The body is optional: an absent or malformed one simply yields no time-zone/language seed
// (the account keeps the UTC / 'en' defaults), so a bind error must not fail the bootstrap.
var req guestAuthRequest
_ = c.ShouldBindJSON(&req)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ, req.Language)
if err != nil {
s.abortErr(c, err)
return
+6
View File
@@ -285,6 +285,12 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
ctx := c.Request.Context()
if res.Merged {
// A guest initiator's merge ran inline (the guest-primary rule; no confirmation step),
// so render the completed merge — the client adopts the switched session and lands on
// the surviving durable account as if it simply logged in.
return s.mergeResultResponse(c, res.Merge)
}
if res.MergeRequired {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
+9 -5
View File
@@ -98,7 +98,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image (build-arg). **Deploy-authoritative**: a redeploy that bumped it **delivers** the version onto the volume (add-only) and **activates** it for new games — old games keep the version they pin, and a newer out-of-band console upload is never downgraded (ARCHITECTURE.md §5). The `.seed_version` marker still guards the flat seed's label (a bump is delivered as a new subdirectory, never a relabel). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
@@ -176,10 +176,14 @@ For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`);
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
default — a missing value fails loudly instead of baking a stale tag.
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
Bumping `DICT_VERSION` and **redeploying** takes a live contour/prod to the new dictionary:
on boot the backend **delivers** the build's version onto the volume (add-only, into
`BACKEND_DICT_DIR/<version>/` from the unshadowed `BACKEND_DICT_SEED_DIR`) and **activates** it,
so new games pin it while in-flight games keep the version they started on. The `.seed_version`
marker still guards the flat seed's label (the delivery is a new subdirectory, never a relabel).
The admin console `/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm)
remains for an **out-of-band** update without a redeploy; a console version newer than the build
survives a later restart on an older image (ARCHITECTURE.md §5).
## Production rollout
+37 -18
View File
@@ -47,7 +47,16 @@ Three executables plus per-platform side-services:
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
Telegram's native **Settings** button into the in-app settings, and syncs the
device-independent display preferences (theme, reduce-motion, board labels — **not** the
interface language) across the user's Telegram devices via **CloudStorage**.
interface language) across the user's Telegram devices via **CloudStorage**. Inside the **VK
Mini App** the same preferences — **plus** the interface language and the first-run coachmark
flags — are instead mirrored to **VK Bridge storage** (`VKWebAppStorageSet`/`Get`,
`lib/vkprefs.ts` + `lib/vk.ts`, reconciled on launch): VK's cross-origin desktop iframe
(notably in Firefox) partitions or blocks the browser's IndexedDB/localStorage, so the local
store is lost across reloads there, while VK Bridge storage travels over the postMessage channel
to vk.com and survives; the VK identity re-derives from the signed launch parameters each load,
so the values reload onto the same account. The locale rides this store because on VK it has no
other durable client home (`preferred_language` is written from the client but never read back).
A plain browser tab / PWA keeps everything in first-party storage.
- **`platform/telegram`** — the Telegram side-service (module
`scrabble/platform/telegram`), split into two binaries that share the bot token
(**one bot**, one optional game channel, §3):
@@ -361,7 +370,12 @@ arrive from a platform rather than completing a mandatory registration).
- **Unlink** detaches a platform identity (`telegram`/`vk`) from the profile. The
backend **refuses removing the last identity** (`ErrLastIdentity`), so an account
never becomes unreachable; the UI mirrors the guard by hiding Unlink when only one
method remains. **Email is never unlinked — it is changed.**
method remains. **Email is never unlinked — it is changed.** **Inside a proprietary Mini App
host (VK or Telegram) the profile surfaces only email linking** — both providers' link and
unlink entries are hidden there (`profileValidation.signInProvidersVisible`), so a competing
sign-in is never advertised: a platform ToS requirement (VK forbids showing a Telegram login,
mirrored in Telegram for VK). A provider linked earlier on the web stays attached and is managed
from the web; the web and native builds show both providers.
- **Change email** mails a confirm-code (`purpose=change`) to the new address on the
authenticated account and, on confirm (code or one-tap deeplink), **atomically
replaces** the account's email identity with the new one, freeing the old address. A
@@ -446,22 +460,27 @@ Key points:
version while new games use the new one. A restart re-loads every resident
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
skipping the `.staging/` upload area) and restores the active pointer from
`dictionary_state`. The volume preserves uploaded versions across redeploys;
once seeded it is not re-seeded, so after bootstrap dictionary changes go through
the console rather than a rebuild. Because the flat DAWGs carry no embedded
version, `OpenWithVersions` records the version the flat directory was first seeded
at in a `.seed_version` marker on the volume and treats that marker as
**authoritative** (the **seed-drift guard**): on an already-seeded volume a later
`BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the
already-seeded bytes — which would otherwise silently serve the wrong dictionary
and void games pinned to the prior label. A running contour therefore moves to a
new release **through the console** (the prior version stays resident, so its games
keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only:
bumping it on a live contour is a harmless no-op that takes effect on the next
fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`.
(The dictionaries ship as a versioned **release artifact** from the
`scrabble-dictionary` repo; the build's `DICT_VERSION` selects
only the seed.)
`dictionary_state`. The volume preserves uploaded versions across redeploys, so
in-flight games keep replaying on the version they pin. The build's
`BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion`
copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the
volume mount hides the image's `BACKEND_DICT_DIR` copy — into
`BACKEND_DICT_DIR/<version>/` when that version is not already resident (**add-only**:
the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it
the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the
new dictionary automatically, while old games stay on theirs — no console step. The
one exception: a version installed out-of-band through the console that is **newer**
than the build version and still resident is kept, so a restart on an older image never
downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no
embedded version, `OpenWithVersions` records the version the flat directory was first
seeded at in a `.seed_version` marker and treats it as authoritative for **that flat
directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels
the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so
the guard still prevents mis-serving a game pinned to the flat label. The console
remains the way to update a dictionary **without** a redeploy (an out-of-band upload).
Set `DICT_VERSION` per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. (The
dictionaries ship as a versioned **release artifact** from the `scrabble-dictionary`
repo.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
+17 -11
View File
@@ -50,7 +50,7 @@ tail — at a real control, and a **tap anywhere** advances to the next; after t
overlay is gone for good. Two independent series run. The **lobby** series points at the
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
@@ -117,18 +117,22 @@ First platform contact auto-provisions a durable account. From the profile a pla
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status
of an identity is never revealed before the code/sign-in is verified. If the linked
identity already belongs to another account, the player is shown an explicit,
**irreversible** confirmation and the two accounts are merged into the one they are
using (statistics summed, games and friends transferred, duplicates removed) — except
when a guest links an identity that already has a durable account, where the durable
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
identity already belongs to another account, the two accounts are merged into one
(statistics summed, games and friends transferred, wallet folded, duplicates removed).
A **durable** initiator is shown an explicit, **irreversible** confirmation first —
consolidating two real accounts is consequential. A **guest** initiator is not: the
durable account that owns the identity is kept, and the ephemeral guest — with its games,
wallet and stats folded in — is retired **seamlessly**, so from the player's side it is
simply signing into their account (the app switches to it with no prompt). A merge is
blocked only while the two accounts share a game still in progress; a guest is then asked
to finish that shared game before signing in.
The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
back); inside a Mini App the host platform is already linked, and its own sign-in-method
row is hidden — the platform the player is currently signed in through cannot be unlinked
from there, only the other provider's row is shown. Linking a provider that
back); inside a proprietary Mini App host (VK or Telegram) the player is offered **only email
linking**both providers' rows and add buttons are hidden there, so a competing sign-in is
never shown (a platform ToS rule). A provider linked earlier on the web stays attached and is
managed from the web. Linking a provider that
already belongs to another account offers the same irreversible **merge** as email
linking. A linked provider can be **unlinked** — except the last
remaining sign-in method, which is refused so the account stays reachable. **Email is
@@ -422,7 +426,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
entry that opens this screen, and your display preferences (theme, board-label style and
reduce-motion — not the interface language, which follows your account) sync across your
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
Telegram devices. Inside the VK Mini App the same preferences — and the interface language —
are remembered between sessions this way too, including VK's desktop web version, where the
browser would otherwise forget them on every reload. On a touch device the settings also offer a **Zoom the board** toggle (on by
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
+16 -11
View File
@@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
@@ -121,18 +121,21 @@ Telegram держит **единого бота**: все игроки поль
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
показывается только плашка другого провайдера. Привязка провайдера, уже
обратно); внутри проприетарного Mini App хоста (VK или Telegram) игроку предлагается **только
привязка email** — плашки обоих провайдеров и кнопки добавления там скрыты, чтобы конкурирующий
способ входа не показывался (требование ToS платформы). Провайдер, привязанный ранее в вебе,
остаётся привязанным и управляется из веба. Привязка провайдера, уже
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
email. Привязанного провайдера можно **отвязать** — кроме последнего
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
@@ -429,7 +432,9 @@ UTC; при создании аккаунта она подставляется
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
Telegram. Внутри VK Mini App те же настройки — и язык интерфейса — так же запоминаются между
сессиями, в том числе в десктопной веб-версии VK, где браузер иначе забывал бы их при каждой
перезагрузке. На сенсорном устройстве в настройках также есть переключатель **«Приближать
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
где доска и так помещается целиком и авто-зума нет.
+2 -2
View File
@@ -53,7 +53,7 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → hints → 🔀 shuffle →
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
@@ -284,7 +284,7 @@ e2e and the screenshots.
opens a dialog — pick tiles to **Exchange N**, or pick
none to **Pass without exchanging**; pass is always available on your turn, exchange only
while the bag still holds a full rack, below which the tiles disable and only the pass
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
remains), Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
+4 -3
View File
@@ -312,11 +312,12 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
}
// GuestAuth provisions a guest account and mints a session, seeding its time zone
// from browserTz (the client's detected "±HH:MM" UTC offset).
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
// from browserTz (the client's detected "±HH:MM" UTC offset) and its interface
// language from language (the client's detected locale; best-effort).
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype, language string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
map[string]string{"browser_tz": browserTz, "subtype": subtype, "language": language}, &out)
return out, err
}
+3 -2
View File
@@ -270,13 +270,14 @@ func authGuestHandler(backend *backendclient.Client) Handler {
// The guest bootstrap historically carried no payload; the detected zone is
// optional, so an absent or empty one simply yields no time-zone seed (rather
// than panicking in GetRootAs* on a zero-length buffer).
var browserTz, subtype string
var browserTz, subtype, locale string
if len(req.Payload) > 0 {
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
browserTz = string(g.BrowserTz())
subtype = string(g.Subtype())
locale = string(g.Locale())
}
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
sess, err := backend.GuestAuth(ctx, browserTz, subtype, locale)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 24
minSdkVersion = 29
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
+4 -3
View File
@@ -97,14 +97,15 @@ test.describe('native offline-first', () => {
await expectOfflineGuestLobby(page);
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
// A minimal create: set the mandatory host (master) PIN, decline a seat, the sole offered variant
// (Erudit — the profileless guest's default), two open seats.
// A minimal create: set the mandatory host (master) PIN. A guest host skips the "do you take a
// seat?" prompt (no name to seat) and goes straight to the two open seats, so pick the sole
// offered variant (Erudit — the profileless guest's default) right after the PIN.
await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await page.locator('.hostpin .plink').click();
await typePin(page, '9999');
await typePin(page, '9999');
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
await expect(page.getByRole('button', { name: /^(No|Нет)$/ })).toHaveCount(0); // no seat prompt for a guest
await page.locator('.variant').click();
await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob');
+17 -8
View File
@@ -10,6 +10,7 @@
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
import { localGuestId } from '../lib/localguest';
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
@@ -1442,21 +1443,29 @@
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
// display name.
// isMe reports whether a seat belongs to the viewer — matched against the server account id OR
// the device-local guest id, the same rule the lobby uses for "my games". A device-local game
// (vs_ai / hotseat) is seated under the local guest id when created offline, and its human seat
// must still read "You" after a merge switches the active account to a durable id; without the
// local-guest arm the seat matched neither and a vs_ai game showed BOTH seats as robots.
function isMe(accountId: string | undefined): boolean {
return !!accountId && (accountId === app.session?.userId || accountId === localGuestId());
}
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
if (isMe(s.accountId)) return t('common.you');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
if (view?.game.vsAi) return '🤖';
if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName;
}
// turnLabel is the under-board status when it is not the viewer's turn: the opponent's name
// once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting).
// turnLabel is the under-board status when it is not the viewer's turn, for online PvP and
// vs_ai (the hotseat branch of the turn strip shows the seat's own name instead): a generic
// "opponent's turn" rather than the opponent's name or the robot mark, so whose turn it is
// reads the same everywhere. The seat row below still names each player.
function turnLabel(): string {
if (view?.game.vsAi) return '🤖';
const s = view?.game.seats[view?.game.toMove ?? -1];
if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName;
return t('game.opponentsTurn');
}
@@ -1736,7 +1745,7 @@
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
lifts live at the unlock moment. -->
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="sq" data-coach="game-hints">{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</button>
{:else}
@@ -1746,7 +1755,7 @@
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="sq" data-coach="game-hints">{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{/if}
+91 -2
View File
@@ -33,13 +33,21 @@ import {
telegramCloudGet,
telegramCloudSet,
} from './telegram';
import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, vkStorageGet, vkStorageSet, appearanceForBg } from './vk';
import type { LaunchDiag } from './launchdiag';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { isStandalone } from './pwa';
import { registerServiceWorker } from './pwa.svelte';
import { haptic } from './haptics';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import {
VK_ONBOARDING_KEY,
VK_PREFS_KEY,
decodeVKOnboarding,
decodeVKPrefs,
encodeVKOnboarding,
encodeVKPrefs,
} from './vkprefs';
import { parseStartParam } from './deeplink';
import {
clearOnboarding,
@@ -662,7 +670,18 @@ async function reconcileServerGuest(): Promise<void> {
*/
export async function applyLinkResult(r: LinkResult): Promise<void> {
if (r.session && r.session.token) {
// A guest initiator's merge switches the session to the surviving durable account. Its
// device-local games (vs_ai / hotseat) were seated under the retired account's id, so
// re-point them to the survivor: the lobby and the game header identify "me" by the active
// account, and without this the merged-away game shows every seat as an opponent (the
// in-game turn logic is seat-index based, so it still plays — the summary just misreads).
const retiredId = app.session?.userId;
await adoptSession(r.session);
const survivorId = app.session?.userId;
if (retiredId && survivorId && retiredId !== survivorId) {
const { repointLocalGameSeats } = await import('./localgame/store');
await repointLocalGameSeats(retiredId, survivorId);
}
return;
}
app.profile = await gateway.profileGet();
@@ -937,6 +956,10 @@ export async function bootstrap(): Promise<void> {
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
});
await vkInit();
// Restore the display prefs + coachmark flags from VK Bridge storage before the first paint — VK's
// desktop iframe drops the browser-local store on reload (Firefox), so this is where the theme,
// language and board settings come back. Awaited so the initial render is already correct.
await reconcileVKPrefs();
// The app owns navigation (its own back chevron), so silence VK's horizontal swipe-back to keep
// it off our edge-swipe-back and on-board tile drag — parity with telegramDisableVerticalSwipes.
void vkDisableSwipeBack();
@@ -971,7 +994,11 @@ export async function bootstrap(): Promise<void> {
// to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
// so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
// A native app is offline-first, so a native launch — even a guest with no email — must fall back
// to the cached session's offline lobby rather than hang ~25 s on adoptSession's retrying profile
// fetch when the device is offline (e.g. airplane mode). Web keeps the installed-PWA + email gate.
const nativeChannel = clientChannel() === 'android' || clientChannel() === 'ios';
const canOffline = !vkcb && (nativeChannel || (!!cachedProfile && isStandalone() && !!cachedProfile.email));
if (canOffline) {
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
gateway.setToken(saved.token);
@@ -1237,6 +1264,9 @@ export function markOnboardingDone(series: CoachSeries): void {
if (series === 'lobby') app.onboarding.lobbyDone = true;
else app.onboarding.gameDone = true;
void saveOnboarding({ ...app.onboarding });
// Mirror to VK Bridge storage too (a no-op outside VK), so the first-run coachmarks do not replay
// on every reload in VK's iframe, where the browser-local flag is dropped.
void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ ...app.onboarding }));
}
/**
@@ -1246,6 +1276,7 @@ export function markOnboardingDone(series: CoachSeries): void {
export function resetOnboarding(): void {
app.onboarding = { lobbyDone: false, gameDone: false };
void clearOnboarding();
void vkStorageSet(VK_ONBOARDING_KEY, encodeVKOnboarding({ lobbyDone: false, gameDone: false }));
}
function persistPrefs(): void {
@@ -1263,6 +1294,19 @@ function persistPrefs(): void {
CLOUD_PREFS_KEY,
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
);
// Mirror the same display prefs — plus the locale — to VK Bridge storage, so they follow the user
// in VK's desktop iframe where Firefox drops IndexedDB / localStorage (a no-op outside VK). The
// locale is included here, unlike the Telegram bundle: on VK it has no other durable client home.
void vkStorageSet(
VK_PREFS_KEY,
encodeVKPrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
}),
);
}
/**
@@ -1294,6 +1338,51 @@ async function reconcileCloudPrefs(): Promise<void> {
if (changed) persistPrefs();
}
/**
* reconcileVKPrefs pulls the display prefs (theme / locale / reduce-motion / board labels / board
* zoom) and the first-run coachmark flags from VK Bridge storage and applies any that are present,
* then refreshes the local cache. It is the VK counterpart of reconcileCloudPrefs: VK's desktop
* iframe loses IndexedDB / localStorage across reloads (Firefox), so the browser-local values read at
* boot are only the defaults, while VK Bridge storage survives this restores what the user last
* chose onto the same VK identity. Awaited in the VK launch branch before the app renders, so the
* first paint already carries the stored theme and language (no flash); VK's own loading cover is
* still up until vkInit. The locale is applied through setLocale, not setLocalePref, so restoring it
* never re-pushes to the server. A no-op outside VK.
*/
async function reconcileVKPrefs(): Promise<void> {
if (!insideVK()) return;
const p = decodeVKPrefs(await vkStorageGet(VK_PREFS_KEY));
if (p.theme !== undefined && p.theme !== app.theme) {
app.theme = p.theme;
applyTheme(app.theme);
syncVKChrome();
}
if (p.locale !== undefined && p.locale !== app.locale) {
app.locale = p.locale;
setLocale(p.locale);
}
if (p.reduceMotion !== undefined && p.reduceMotion !== app.reduceMotion) {
app.reduceMotion = p.reduceMotion;
applyReduceMotion(app.reduceMotion);
}
if (p.boardLabels !== undefined) app.boardLabels = p.boardLabels;
if (p.zoomBoard !== undefined) app.zoomBoard = p.zoomBoard;
const ob = decodeVKOnboarding(await vkStorageGet(VK_ONBOARDING_KEY));
if (ob.lobbyDone !== undefined) app.onboarding.lobbyDone = ob.lobbyDone;
if (ob.gameDone !== undefined) app.onboarding.gameDone = ob.gameDone;
// Refresh the browser-local cache with what we restored (best-effort — it may be blocked in this
// iframe, and VK Bridge storage stays the durable copy). savePrefs directly, not persistPrefs, to
// avoid writing straight back to the VK/Telegram stores we just read from.
void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
});
void saveOnboarding({ ...app.onboarding });
}
export function setTheme(theme: ThemePref): void {
app.theme = theme;
applyTheme(theme);
+2 -1
View File
@@ -91,7 +91,7 @@ export const en = {
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
'onboarding.gameHints': 'Available hints', // 🛟
'onboarding.gameHints': 'Available hints', //
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
@@ -312,6 +312,7 @@ export const en = {
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.",
'error.chat_not_your_turn': 'You can chat only on your turn.',
'error.merge_active_game_conflict': 'You have an unfinished game with that account. Finish it, then sign in again.',
'error.chat_already_sent': 'You can send only one message per turn.',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
+2 -1
View File
@@ -91,7 +91,7 @@ export const ru: Record<MessageKey, string> = {
'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲
'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской
'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄
'onboarding.gameHints': 'Доступные подсказки', // 🛟
'onboarding.gameHints': 'Доступные подсказки', //
'onboarding.gameShuffle': 'Встряхните фишки', // 🔀
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
@@ -312,6 +312,7 @@ export const ru: Record<MessageKey, string> = {
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.merge_active_game_conflict': 'У вас есть незавершённая партия с этим аккаунтом. Доиграйте её и войдите снова.',
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
+20
View File
@@ -81,6 +81,26 @@ export async function listLocalGames(): Promise<LocalGameRecord[]> {
}
}
/** repointLocalGameSeats rewrites the account id on every stored game's seats from oldId to
* newId. It is run when an account merge retires the account a device-local game was seated
* under (a guest that logged into a durable account): the surviving account then owns the
* games, so the lobby and the game header identify "me" again without it the human seat no
* longer matches the active account and the game shows every seat as an opponent. Best-effort;
* the records read from IndexedDB are plain objects, so mutating and re-saving them is safe. */
export async function repointLocalGameSeats(oldId: string, newId: string): Promise<void> {
if (!oldId || !newId || oldId === newId) return;
for (const g of await listLocalGames()) {
let touched = false;
for (const s of g.seats) {
if (s.accountId === oldId) {
s.accountId = newId;
touched = true;
}
}
if (touched) await saveLocalGame(g);
}
}
/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
export async function deleteLocalGame(id: string): Promise<void> {
const db = openDb();
+4 -1
View File
@@ -175,7 +175,10 @@ function applyEffect(effect: Effect): void {
* poll immediately (the native reconcile) rather than after the first interval.
*/
export function bootOffline(kickNow = false): void {
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
// provisional: this offline is an assumption, not an observation — the first probe result confirms
// it (silent heal if reachable, so a first launch that had connectivity does not flash a spurious
// "back online" toast) or turns it into a real offline (see the reducer).
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };
arm(kickNow ? 0 : OFFLINE_POLL_MS);
}
+28
View File
@@ -248,3 +248,31 @@ describe('reduce — purity', () => {
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
});
});
// A boot-assumed (provisional) offline — bootOffline sets this — heals SILENTLY on its first
// successful probe (a first launch that actually had connectivity must not flash "back online"),
// while a failed probe confirms it as a real offline whose later recovery does toast.
describe('provisional (boot-assumed) offline', () => {
const bootOffline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };
it('heals to online with NO toast when the first probe succeeds', () => {
const r = reduce(bootOffline, e('probeOk'), cfg);
expect(r.next.state).toBe('online');
expect(r.effects).toEqual([]); // no "back online" toast — it was never really offline
});
it('clears provisional on a failed probe, then toasts on the eventual recovery', () => {
const failed = reduce(bootOffline, e('probeFailed'), cfg);
expect(failed.next.state).toBe('offlineNoNetwork');
expect(failed.next.provisional).toBe(false);
expect(failed.effects).toEqual([]);
const healed = reduce(failed.next, e('probeOk'), cfg);
expect(healed.next.state).toBe('online');
expect(healed.effects).toEqual([{ kind: 'toast', toast: 'online' }]); // now a real recovery
});
it('a non-provisional (observed) offline still toasts on recovery', () => {
const r = reduce(offline, e('probeOk'), cfg);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
});
+18 -3
View File
@@ -74,6 +74,14 @@ export interface NetSnapshot {
* it (0 while not `connecting`).
*/
connectingSince: number;
/**
* provisional marks an `offlineNoNetwork` that was ASSUMED at boot (bootOffline), not observed from
* a real onlineoffline transition. The first probe confirms it: a success means we were reachable
* all along, so the machine heals to online SILENTLY (no "back online" toast there was nothing to
* come back from); a failure clears the flag, turning it into a confirmed offline whose later
* recovery does toast. Absent/false in every non-boot snapshot.
*/
provisional?: boolean;
}
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
@@ -198,13 +206,20 @@ export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResu
switch (ev.type) {
case 'callOk':
case 'probeOk':
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
// The probe (or a reconcile call) won — heal to online. A boot-assumed (provisional) offline
// that is reachable on its very first probe was never really offline, so heal SILENTLY; a
// confirmed offline gets the "back online" toast.
return { next: onlineSnapshot(), effects: prev.provisional ? [] : [{ kind: 'toast', toast: 'online' }] };
case 'callFailed':
case 'probeFailed':
// A failed probe confirms a boot-assumed offline as real (its eventual recovery will then
// toast); a non-provisional offline is unchanged.
return prev.provisional ? { next: { ...prev, provisional: false }, effects: [] } : { next: prev, effects: [] };
case 'osOnline':
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
// osOffline (still offline), versionRecommended (no nudge): no-op.
return { next: prev, effects: [] };
}
}
+19 -1
View File
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { awayDurationOk, browserOffset, isOffsetZone, validDisplayName, validEmail } from './profileValidation';
import {
awayDurationOk,
browserOffset,
isOffsetZone,
signInProvidersVisible,
validDisplayName,
validEmail,
} from './profileValidation';
describe('validDisplayName', () => {
it.each([
@@ -61,3 +68,14 @@ describe('timezone helpers', () => {
expect(browserOffset()).toMatch(/^[+-]\d{2}:\d{2}$/);
});
});
describe('signInProvidersVisible', () => {
it('shows both providers off a proprietary host (web / native)', () => {
expect(signInProvidersVisible(false, false)).toBe(true);
});
it('hides all providers inside a Telegram or VK host (email-only, ToS)', () => {
expect(signInProvidersVisible(true, false)).toBe(false);
expect(signInProvidersVisible(false, true)).toBe(false);
expect(signInProvidersVisible(true, true)).toBe(false);
});
});
+11
View File
@@ -87,3 +87,14 @@ export const awayHours: string[] = Array.from({ length: 24 }, (_, i) => String(i
/** Minute options on a 10-minute step. */
export const awayMinutes: string[] = ['00', '10', '20', '30', '40', '50'];
/**
* signInProvidersVisible reports whether the profile may surface competing sign-in providers the
* Telegram and VK link / unlink entries. Inside a proprietary Mini App host (Telegram or VK) only
* email linking is offered, a platform ToS requirement: VK forbids showing a Telegram login, and we
* mirror it in Telegram for VK. The web and native builds are not proprietary hosts, so they show
* both providers. Pure, so it is unit-tested.
*/
export function signInProvidersVisible(insideTelegramHost: boolean, insideVKHost: boolean): boolean {
return !insideTelegramHost && !insideVKHost;
}
+34
View File
@@ -65,6 +65,40 @@ export async function vkClose(): Promise<void> {
}
}
/**
* vkStorageGet reads a value from the user's VK Bridge storage (VKWebAppStorageGet), resolving null
* when the key is unset, VK is unavailable (outside a VK launch / the bridge cannot answer) or the
* read errors so the caller can fall back to the local value. VK Bridge storage travels over the
* postMessage channel to vk.com rather than browser storage, so it survives the desktop iframe's
* partitioned or blocked IndexedDB and localStorage (notably in Firefox), unlike the local store.
*/
export async function vkStorageGet(key: string): Promise<string | null> {
if (!insideVK()) return null;
try {
const r = (await (await bridge()).send('VKWebAppStorageGet', { keys: [key] })) as {
keys?: { key: string; value: string }[];
};
const item = r.keys?.find((k) => k.key === key);
return item && item.value !== '' ? item.value : null;
} catch {
return null;
}
}
/**
* vkStorageSet writes value to the user's VK Bridge storage under key (VKWebAppStorageSet). It is
* best-effort: a no-op outside VK, and it swallows write errors, since the browser-local store stays
* the in-session source of truth.
*/
export async function vkStorageSet(key: string, value: string): Promise<void> {
if (!insideVK()) return;
try {
await (await bridge()).send('VKWebAppStorageSet', { key, value });
} catch {
// Outside VK / unsupported / over quota: the local store keeps the value for this session.
}
}
/**
* vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
+69
View File
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
VK_ONBOARDING_KEY,
VK_PREFS_KEY,
decodeVKOnboarding,
decodeVKPrefs,
encodeVKOnboarding,
encodeVKPrefs,
} from './vkprefs';
describe('vkprefs', () => {
it('round-trips the synced display prefs', () => {
const p = {
theme: 'dark',
locale: 'ru',
reduceMotion: true,
boardLabels: 'classic',
zoomBoard: false,
} as const;
expect(decodeVKPrefs(encodeVKPrefs(p))).toEqual(p);
});
it('carries the locale (unlike the Telegram bundle)', () => {
const raw = encodeVKPrefs({
theme: 'light',
locale: 'en',
reduceMotion: false,
boardLabels: 'none',
zoomBoard: true,
});
expect(raw).toContain('locale');
expect(decodeVKPrefs(raw).locale).toBe('en');
});
it('returns an empty partial for missing or malformed prefs input', () => {
expect(decodeVKPrefs(null)).toEqual({});
expect(decodeVKPrefs(undefined)).toEqual({});
expect(decodeVKPrefs('')).toEqual({});
expect(decodeVKPrefs('not json')).toEqual({});
expect(decodeVKPrefs('[1,2,3]')).toEqual({});
});
it('keeps only valid prefs fields and drops unknown or mistyped ones', () => {
const raw = JSON.stringify({
theme: 'neon',
locale: 'de',
reduceMotion: 'yes',
boardLabels: 'classic',
zoomBoard: 1,
extra: true,
});
expect(decodeVKPrefs(raw)).toEqual({ boardLabels: 'classic' });
});
it('round-trips the onboarding flags', () => {
const s = { lobbyDone: true, gameDone: false } as const;
expect(decodeVKOnboarding(encodeVKOnboarding(s))).toEqual(s);
});
it('keeps only boolean onboarding flags', () => {
expect(decodeVKOnboarding(null)).toEqual({});
expect(decodeVKOnboarding(JSON.stringify({ lobbyDone: 1, gameDone: true }))).toEqual({ gameDone: true });
});
it('exposes the VK Bridge storage keys', () => {
expect(VK_PREFS_KEY).toBe('prefs');
expect(VK_ONBOARDING_KEY).toBe('onboarding');
});
});
+104
View File
@@ -0,0 +1,104 @@
// VK Bridge storage sync for the client display preferences that VK's desktop iframe drops between
// reloads — theme, interface language, reduce-motion, board labels and the board zoom, plus the
// first-run coachmark flags. In VK's cross-origin desktop iframe (notably in Firefox) the browser
// partitions or blocks IndexedDB and localStorage, so the app's local store is empty on every reload
// and these settings reset; a plain browser tab / PWA keeps them (first-party storage) and Telegram
// has its own CloudStorage sync (cloudprefs.ts). VKWebAppStorageSet/Get travel over the VK Bridge
// postMessage channel to vk.com rather than browser storage, so they survive; the VK identity is
// re-derived from the signed launch parameters on every load, so the stored values reload onto the
// same account. The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the
// node environment; the VK storage transport wrappers live in vk.ts and the wiring (mirror on save,
// reconcile on launch) in app.svelte.ts.
//
// Unlike the Telegram CloudStorage bundle (cloudprefs.ts), this one DOES carry the locale: on VK the
// interface language has no other durable client home (the server preferred_language is written from
// the client but never read back into it), so it must ride the same store as the rest.
import type { ThemePref } from './theme';
import type { BoardLabelMode } from './boardlabels';
import type { Locale } from './i18n/catalog';
/** VKPrefs is the display-preference bundle synced to VK Bridge storage. */
export interface VKPrefs {
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
zoomBoard: boolean;
}
/** VKOnboarding mirrors the first-run coachmark completion flags to VK Bridge storage. */
export interface VKOnboarding {
lobbyDone: boolean;
gameDone: boolean;
}
/** VK_PREFS_KEY is the VK Bridge storage key holding the JSON-encoded VKPrefs. */
export const VK_PREFS_KEY = 'prefs';
/** VK_ONBOARDING_KEY is the VK Bridge storage key holding the JSON-encoded VKOnboarding flags. */
export const VK_ONBOARDING_KEY = 'onboarding';
/** encodeVKPrefs serialises the VK-synced display prefs. */
export function encodeVKPrefs(p: VKPrefs): string {
return JSON.stringify({
theme: p.theme,
locale: p.locale,
reduceMotion: p.reduceMotion,
boardLabels: p.boardLabels,
zoomBoard: p.zoomBoard,
});
}
/**
* decodeVKPrefs parses a VK Bridge storage payload into a partial VKPrefs, keeping only valid fields
* and dropping anything unknown, mistyped or malformed so a value written by a newer or older
* build, or a corrupt entry, never throws and never applies a bad setting. A missing field stays
* absent, so the caller leaves the corresponding local value untouched.
*/
export function decodeVKPrefs(raw: string | null | undefined): Partial<VKPrefs> {
const o = parseObject(raw);
if (!o) return {};
const out: Partial<VKPrefs> = {};
if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme;
if (o.locale === 'en' || o.locale === 'ru') out.locale = o.locale;
if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion;
if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') {
out.boardLabels = o.boardLabels;
}
if (typeof o.zoomBoard === 'boolean') out.zoomBoard = o.zoomBoard;
return out;
}
/** encodeVKOnboarding serialises the first-run coachmark completion flags. */
export function encodeVKOnboarding(s: VKOnboarding): string {
return JSON.stringify({ lobbyDone: s.lobbyDone, gameDone: s.gameDone });
}
/**
* decodeVKOnboarding parses a VK Bridge storage payload into a partial VKOnboarding, keeping only the
* boolean flags and dropping anything else a missing flag stays absent so the caller keeps its
* current value.
*/
export function decodeVKOnboarding(raw: string | null | undefined): Partial<VKOnboarding> {
const o = parseObject(raw);
if (!o) return {};
const out: Partial<VKOnboarding> = {};
if (typeof o.lobbyDone === 'boolean') out.lobbyDone = o.lobbyDone;
if (typeof o.gameDone === 'boolean') out.gameDone = o.gameDone;
return out;
}
/**
* parseObject safely parses raw JSON into a plain object, returning null for empty, malformed or
* non-object input (including a JSON array), so every decoder above starts from a clean record.
*/
function parseObject(raw: string | null | undefined): Record<string, unknown> | null {
if (!raw) return null;
try {
const o = JSON.parse(raw) as unknown;
return o && typeof o === 'object' && !Array.isArray(o) ? (o as Record<string, unknown>) : null;
} catch {
return null;
}
}
+4 -1
View File
@@ -265,7 +265,10 @@
case 'host-set':
if (r.kind === 'set') {
hostPin = r.lock;
askHostPlays = true;
// A guest host has no meaningful display name to seat, so the "do you take a seat?"
// prompt would only produce a nameless seat — skip it and go straight to the (empty)
// player seats. A durable host is still asked.
if (!guest) askHostPlays = true;
}
break;
case 'host-change':
+34 -31
View File
@@ -26,6 +26,7 @@
awayMinutes,
browserOffset,
isOffsetZone,
signInProvidersVisible,
timezoneOffsets,
validDisplayName,
validEmail,
@@ -67,11 +68,11 @@
const nativeShell = clientChannel() === 'android' || clientChannel() === 'ios';
const telegramLinkable = loginWidgetAvailable() && !nativeShell;
const vkLinkable = vkWebLinkAvailable() && !nativeShell;
// Inside a host Mini App the current platform's own identity must not be managed from the
// profile — unlinking the very platform you are signed in through is meaningless — so that
// provider's linked-account row is hidden here (the web / native builds still show both).
const hostTelegram = insideTelegram();
const hostVK = insideVK();
// Inside a proprietary Mini App host (VK or Telegram) the profile shows only email linking — the
// competing provider entries are hidden there (a platform ToS requirement; see
// signInProvidersVisible). A provider linked earlier on the web stays linked in the account and is
// managed from the web.
const providersVisible = signInProvidersVisible(insideTelegram(), insideVK());
function defaultTz(): string {
const b = browserOffset();
@@ -429,9 +430,9 @@
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
host provider is already linked, so its own row is hidden — the platform you are signed
in through cannot be unlinked from here (the other provider's row still shows). Email is
login widget or VK via VK ID web login (a full-page redirect). Inside a proprietary Mini
App host (VK or Telegram) only email is offeredboth provider rows and Link buttons are
hidden (signInProvidersVisible), a ToS requirement not to surface a competing sign-in there. Email is
never unlinked — it is changed. Unlink is offered only when another identity remains
(the backend also refuses the last one). -->
<section class="accounts">
@@ -480,30 +481,32 @@
</div>
{/if}
{#if p.telegramLinked && !hostTelegram}
<div class="acctrow">
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{:else if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
</button>
{/if}
{#if providersVisible}
{#if p.telegramLinked}
<div class="acctrow">
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{:else if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
</button>
{/if}
{#if p.vkLinked && !hostVK}
<div class="acctrow">
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{:else if vkLinkable}
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
</button>
{#if p.vkLinked}
<div class="acctrow">
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
{#if canUnlink}
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{:else if vkLinkable}
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
</button>
{/if}
{/if}
</section>