Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c0daeb177 | |||
| b285e27d55 | |||
| 3dfc0932ef | |||
| e04e97fb66 | |||
| ebc413f5e5 | |||
| d48797e297 | |||
| fe29c6fe58 | |||
| 3429dbec5f | |||
| 74026223ee | |||
| 896b7f57a7 | |||
| aa803260a1 | |||
| 8758cbd35d | |||
| 1d306ec0fc | |||
| 513b772cd4 | |||
| da25eac070 | |||
| 48614e622d | |||
| f8aeec8008 |
+698
@@ -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 (2–4), 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 2–4 ·
|
||||
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 **2–4** 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 2–4 (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 min–24 h, default 24 h) · **hints** (allowed? how many each) ·
|
||||
**leaver's-tile disposition** (returned to bag / removed — 3–4-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, 2–4) → a **settings
|
||||
step** reusing the per-game settings set → **Send** (+ shareable link). Setting controls: move
|
||||
timeout = native picker (5 min–24 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
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 29
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Вы не участник этой игры.',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 online→offline 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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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 offered — both 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user