Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d48797e297 | |||
| fe29c6fe58 | |||
| 3429dbec5f | |||
| 74026223ee | |||
| 896b7f57a7 | |||
| aa803260a1 | |||
| 8758cbd35d | |||
| 1d306ec0fc | |||
| 513b772cd4 | |||
| da25eac070 | |||
| 48614e622d | |||
| f8aeec8008 | |||
| b5fe61279b | |||
| e07886ecb1 | |||
| 4a3e12c85a | |||
| 54bc31c619 | |||
| d89438040b | |||
| 363f1632b1 | |||
| f0b7ad47d4 | |||
| 0e11817654 | |||
| d0e473920c | |||
| 0e56e4de9a | |||
| 8d3f862b41 |
@@ -3,7 +3,7 @@
|
||||
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
||||
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
||||
#
|
||||
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
||||
# Signing degrades gracefully: with the ANDROID_RUSTORE_* secrets present the APK is signed; without
|
||||
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
||||
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
|
||||
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
|
||||
@@ -144,7 +144,7 @@ jobs:
|
||||
- name: Decode the release keystore
|
||||
id: keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||
@@ -152,16 +152,16 @@ jobs:
|
||||
echo "keystore decoded -> signed release build"
|
||||
else
|
||||
echo "file=" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||
echo "::warning::ANDROID_RUSTORE_KEYSTORE_BASE64 secret is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||
fi
|
||||
|
||||
- name: Assemble the release APK
|
||||
working-directory: ui/android
|
||||
env:
|
||||
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }}
|
||||
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||
|
||||
- name: Upload the APK artifact
|
||||
|
||||
@@ -62,19 +62,20 @@ client-only (no server change).
|
||||
|
||||
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
|
||||
|
||||
> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep)
|
||||
> ### ▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag)
|
||||
>
|
||||
> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are
|
||||
> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
|
||||
> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
|
||||
> **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the
|
||||
> release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The
|
||||
> RuStore MVP is now gated on **one owner-side item** — the **RuStore developer account** (owner going
|
||||
> ИП; RuStore updated its requirements) — plus the agent's **promote/tag → signed-build** chain. Also
|
||||
> landed on `development` since: the payments **E10 multi-shop split** (#266) + **E11 kill switch**
|
||||
> (#267), both dormant / fail-open (no bearing on the Android build), and the signing-secret rename
|
||||
> `ANDROID_*` → **`ANDROID_RUSTORE_*`** (#268). Do not re-do the finished work or re-ask the locked
|
||||
> decisions.
|
||||
>
|
||||
> **Branches / PRs (current working branch: `feature/android-release-prep`).**
|
||||
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
|
||||
> test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN.
|
||||
> - `feature/android-release-prep` — the **release-prep hygiene**, **PR #254 OPEN, green, NOT merged**
|
||||
> (the owner chose to hold the merge). It is branched off `development`, so it also contains the
|
||||
> legal-documents track. Merge #254 into `development` when ready — needs **owner approval** (the
|
||||
> agent cannot self-approve).
|
||||
> **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep
|
||||
> hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the
|
||||
> signing-secret rename (#268). No open Android branch.
|
||||
>
|
||||
> **Done this session.**
|
||||
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
|
||||
@@ -113,12 +114,12 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
||||
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
|
||||
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
|
||||
> `X-Client-Version` header).
|
||||
> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks
|
||||
> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea
|
||||
> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` /
|
||||
> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated).
|
||||
> The AGENT provides the exact commands on request. Without the secrets the workflow builds an
|
||||
> UNSIGNED APK (a dry run still proves the pipeline).
|
||||
> 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`,
|
||||
> PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific
|
||||
> names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` /
|
||||
> `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated —
|
||||
> the cert is pinned at the first RuStore upload). Google Play, later, would use its own
|
||||
> `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key.
|
||||
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
|
||||
> publication, not the build.
|
||||
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
|
||||
@@ -132,6 +133,16 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
|
||||
> 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to
|
||||
> the store deep-link so the update button works on future gated releases (empty until then — the
|
||||
> gate is dormant in the MVP).
|
||||
>
|
||||
> **Next tracks (design in a future session — interview the owner first).**
|
||||
> - **Native notifications (push).** Promote FCM/push from "Out of scope" to a real track: a Capacitor
|
||||
> push plugin, the Android send path (FCM — but **check RuStore's own push service vs FCM on RuStore
|
||||
> devices**, since Google Play Services may be absent), token registration, and reusing the existing
|
||||
> server notification dispatcher. Owner will bring specifics.
|
||||
> - **A test APK for the emulator** (owner asked): the signed APK comes from `android-build` (needs the
|
||||
> promote/tag chain, step 2). A quick **debug** APK is buildable without the release chain if the host
|
||||
> has the Android SDK + JDK 21 (`pnpm build` → `cap sync` → `assembleDebug`).
|
||||
> - Possibly other native polish (owner to flag).
|
||||
|
||||
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
|
||||
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
|
||||
|
||||
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 19 KiB |
@@ -19,6 +19,11 @@ const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
|
||||
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
|
||||
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
||||
const MONO = uri(join(DIR, 'android-monochrome.png'));
|
||||
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
|
||||
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
|
||||
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
|
||||
// the master's corner ✻).
|
||||
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png'));
|
||||
|
||||
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
|
||||
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
|
||||
@@ -38,8 +43,9 @@ for (const [d, [fg, lg]] of Object.entries(D)) {
|
||||
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
|
||||
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
|
||||
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
||||
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
|
||||
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
|
||||
// composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
|
||||
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false));
|
||||
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||
console.log('mipmap-' + d, 'ok');
|
||||
|
||||
@@ -33,8 +33,12 @@ const SHADE = 0.15; // black, this alpha bottom-left -> transp
|
||||
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
|
||||
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
|
||||
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
|
||||
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻):
|
||||
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
|
||||
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
|
||||
// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
|
||||
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
|
||||
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
|
||||
// legacy round icon) get more breathing room; the full-bleed master is untouched.
|
||||
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
|
||||
|
||||
const PALETTE = {
|
||||
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -257,8 +261,8 @@ web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release
|
||||
the SDK is missing or unreadable.
|
||||
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
||||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
|
||||
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||
then set the Gitea **secrets** `ANDROID_RUSTORE_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||
`ANDROID_RUSTORE_KEYSTORE_PASSWORD`, `ANDROID_RUSTORE_KEY_ALIAS`, `ANDROID_RUSTORE_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||
variable to the store listing once published (empty until then — the in-app update button no-ops).
|
||||
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
|
||||
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
||||
|
||||
@@ -446,22 +446,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**
|
||||
|
||||
@@ -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,12 +117,15 @@ 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
|
||||
|
||||
@@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к
|
||||
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
|
||||
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
|
||||
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
|
||||
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||
✨ подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
|
||||
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
|
||||
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
|
||||
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
|
||||
@@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль
|
||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
|
||||
принадлежит другому аккаунту, игроку показывают явное **необратимое**
|
||||
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
|
||||
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
|
||||
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
|
||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
|
||||
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
|
||||
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
|
||||
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
|
||||
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
|
||||
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
|
||||
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
|
||||
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
|
||||
|
||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||
|
||||
@@ -54,7 +54,10 @@ already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
|
||||
full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
|
||||
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
|
||||
|
||||
- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite)
|
||||
- `mipmap-*/ic_launcher.png` — legacy square (< API 26): the **full-bleed master** (large
|
||||
mark), so old square launchers do not shrink it into the safe zone
|
||||
- `mipmap-*/ic_launcher_round.png` — legacy round (< API 26): the safe-zone **composite**,
|
||||
circle-clipped (a round mask would clip the master's corner ✻)
|
||||
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
|
||||
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
|
||||
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no `<inset>`**, with
|
||||
|
||||
@@ -123,12 +123,13 @@ So Android is **not** the full-bleed master — it is built as two layers:
|
||||
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
|
||||
layer, so the sacrificial outer ring is just more wood.
|
||||
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
|
||||
the mask:
|
||||
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
|
||||
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
|
||||
the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
|
||||
scaled **0.75 about the icon centre** — **25 % smaller**, its «Э» ↔ ✻ arrangement
|
||||
unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 %
|
||||
safe zone** with margin to spare:
|
||||
- «Э»: box height **40.4 %**, box center **51.8 % / 49.6 %**.
|
||||
- ✻: outer diameter **9.9 %** (smaller than the master's), center **69.7 % / 67.0 %**,
|
||||
tucked closer to the letter.
|
||||
- The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
|
||||
centred under the round mask, and the whole group fits within the **61 % safe zone**.
|
||||
|
||||
Everything else (palette, grain, gradients, star construction) is identical to the master.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package connectsrv
|
||||
|
||||
import "net/http"
|
||||
|
||||
// nativeWebViewOrigins are the browser Origins of the packaged native apps. Capacitor serves the
|
||||
// bundled SPA from a localhost scheme, so its Connect calls to the gateway are cross-origin; the web
|
||||
// app is same-origin and needs no entry. Requests carry Authorization, so the allowlist reflects the
|
||||
// exact origin rather than "*".
|
||||
var nativeWebViewOrigins = map[string]bool{
|
||||
"https://localhost": true, // Capacitor Android (default https scheme)
|
||||
"http://localhost": true, // Capacitor with the http scheme
|
||||
"capacitor://localhost": true, // Capacitor iOS default scheme
|
||||
}
|
||||
|
||||
// withNativeCORS answers the CORS preflight and adds the CORS response headers for the packaged
|
||||
// native apps' cross-origin calls to the Connect edge. Without it the WebView blocks every RPC on the
|
||||
// preflight (the gateway otherwise returns 405 with no Access-Control-Allow-Origin), so a native
|
||||
// build can never reach the gateway and stays stuck offline. Same-origin (web) and any non-native
|
||||
// origin are untouched.
|
||||
func withNativeCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if nativeWebViewOrigins[origin] {
|
||||
h := w.Header()
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
h.Add("Vary", "Origin")
|
||||
h.Set("Access-Control-Allow-Credentials", "true")
|
||||
// The client interceptor reads the soft-tier update signal off the response.
|
||||
h.Set("Access-Control-Expose-Headers", "X-Update-Recommended")
|
||||
if r.Method == http.MethodOptions {
|
||||
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
// Reflect the requested headers (the origin is already allowlisted): the Connect client
|
||||
// sends Connect-Protocol-Version / Connect-Timeout-Ms plus X-Client-Version and
|
||||
// Authorization, and the exact set varies by call.
|
||||
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||
h.Set("Access-Control-Allow-Headers", reqHeaders)
|
||||
} else {
|
||||
h.Set("Access-Control-Allow-Headers", "Content-Type, Connect-Protocol-Version, X-Client-Version, Authorization")
|
||||
}
|
||||
h.Set("Access-Control-Max-Age", "86400")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package connectsrv
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithNativeCORS(t *testing.T) {
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
h := withNativeCORS(next)
|
||||
|
||||
// Native-origin preflight → 204 with the CORS headers, reflecting the requested headers, without
|
||||
// reaching the inner handler.
|
||||
req := httptest.NewRequest(http.MethodOptions, "/scrabble.edge.v1.Gateway/Execute", nil)
|
||||
req.Header.Set("Origin", "https://localhost")
|
||||
req.Header.Set("Access-Control-Request-Method", "POST")
|
||||
req.Header.Set("Access-Control-Request-Headers", "content-type,connect-protocol-version,x-client-version")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("preflight status = %d, want 204", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://localhost" {
|
||||
t.Errorf("Allow-Origin = %q, want https://localhost", got)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type,connect-protocol-version,x-client-version" {
|
||||
t.Errorf("Allow-Headers = %q, want the reflected set", got)
|
||||
}
|
||||
|
||||
// Native-origin POST → the CORS headers are set and the inner handler runs.
|
||||
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
|
||||
req.Header.Set("Origin", "capacitor://localhost")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST status = %d, want 200 (passed through)", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
|
||||
t.Errorf("POST Allow-Origin = %q, want capacitor://localhost", got)
|
||||
}
|
||||
|
||||
// A non-native origin gets no CORS headers and still passes through (web is same-origin anyway).
|
||||
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("non-native POST status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
|
||||
t.Errorf("non-native Allow-Origin = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// honeypot hit is turned away before the body cap and the mux. Every request
|
||||
// body on the public listener is then capped (the admin proxy POSTs included);
|
||||
// the h2c server carries explicit stream/idle sizing.
|
||||
return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{
|
||||
return h2c.NewHandler(s.abuseGuard(withNativeCORS(maxBodyHandler(s.maxBodyBytes, mux))), &http2.Server{
|
||||
MaxConcurrentStreams: h2cMaxConcurrentStreams,
|
||||
IdleTimeout: h2cIdleTimeout,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 19 KiB |
@@ -24,6 +24,17 @@ allprojects {
|
||||
}
|
||||
}
|
||||
|
||||
// Pin build-tools to the compileSdk-matching version for every Android module (the app + the
|
||||
// Capacitor modules regenerated by `cap sync`), so AGP does not fall back to its default (35.0.0),
|
||||
// which the provisioned / read-only CI SDK does not have installed.
|
||||
subprojects {
|
||||
afterEvaluate {
|
||||
if (project.hasProperty('android')) {
|
||||
android.buildToolsVersion = "36.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
minSdkVersion = 29
|
||||
compileSdkVersion = 36
|
||||
targetSdkVersion = 36
|
||||
androidxActivityVersion = '1.11.0'
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 24 KiB |
@@ -43,20 +43,22 @@ test.describe('native offline-first', () => {
|
||||
await page.goto('/');
|
||||
await expectOfflineGuestLobby(page);
|
||||
|
||||
// Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
|
||||
// comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
|
||||
// network is needed — this is the device-local guest playing with zero connectivity.
|
||||
// Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
|
||||
// only variant the profileless offline guest is offered (the new-account default), so the single
|
||||
// `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from
|
||||
// the APK's bundled tier (./dict/ru_erudit@dev.dawg served from dist-e2e), so no network is needed
|
||||
// — this is the device-local guest playing with zero connectivity.
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
await page.locator('.variant').click();
|
||||
await page.locator('button.invite').click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
|
||||
// The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a
|
||||
// real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
|
||||
await placeTile(page, 'W', 7, 5);
|
||||
await placeTile(page, 'A', 7, 6);
|
||||
await placeTile(page, 'Y', 7, 7);
|
||||
// The human plays НОЖ horizontally across the centre (7,5)-(7,7); the local robot replies with a
|
||||
// real move (which needs the bundled dictionary), so the board gains tiles beyond НОЖ's three.
|
||||
await placeTile(page, 'Н', 7, 5);
|
||||
await placeTile(page, 'О', 7, 6);
|
||||
await placeTile(page, 'Ж', 7, 7);
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||
await page.locator('.make').click();
|
||||
await expect(async () => {
|
||||
@@ -95,14 +97,16 @@ 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, English, 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 page.locator('.variant', { hasText: 'Scrabble' }).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');
|
||||
await page.locator('button.invite').click();
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
@@ -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}
|
||||
|
||||
@@ -662,7 +662,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();
|
||||
@@ -971,7 +982,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);
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
|
||||
import {
|
||||
ALL_VARIANTS,
|
||||
DEFAULT_VARIANTS,
|
||||
availableVariants,
|
||||
supportsMultipleWordsToggle,
|
||||
multipleWordsForRequest,
|
||||
@@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => {
|
||||
});
|
||||
|
||||
describe('availableVariants', () => {
|
||||
it('is ungated (all variants) for an empty or absent preference set', () => {
|
||||
expect(availableVariants([])).toEqual(ALL_VARIANTS);
|
||||
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
|
||||
it('falls back to the Erudit-only default for an empty or absent preference set', () => {
|
||||
const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id));
|
||||
expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']);
|
||||
expect(availableVariants([])).toEqual(fallback);
|
||||
expect(availableVariants(undefined)).toEqual(fallback);
|
||||
});
|
||||
|
||||
it('offers only the preferred variants', () => {
|
||||
|
||||
@@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean {
|
||||
return v === 'erudit_ru';
|
||||
}
|
||||
|
||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
|
||||
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
|
||||
// — a safety fallback; a real profile always carries at least one preference.
|
||||
// DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
|
||||
// or offline native client whose profile has not been synced yet (the device-local guest boots
|
||||
// with no profile at all). It mirrors the backend's new-account default (the account service
|
||||
// seeds Erudit only), so the local guest and a later synced account agree on what is enabled.
|
||||
// Every dictionary is still bundled; the other variants are simply off until the player turns
|
||||
// them on in Settings.
|
||||
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
|
||||
|
||||
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they
|
||||
// enabled in Settings). An empty or absent set falls back to DEFAULT_VARIANTS rather than every
|
||||
// variant: a real profile always carries at least one preference, and a profileless client (a
|
||||
// fresh offline native launch) must match the server's Erudit-only default instead of exposing
|
||||
// the English game before the player opts in.
|
||||
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
|
||||
const prefs = preferences ?? [];
|
||||
if (prefs.length === 0) return ALL_VARIANTS;
|
||||
return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
|
||||
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
|
||||
return ALL_VARIANTS.filter((v) => effective.includes(v.id));
|
||||
}
|
||||
|
||||
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
|
||||
|
||||
@@ -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':
|
||||
|
||||