diff --git a/backend/Dockerfile b/backend/Dockerfile index b593d33..c3150af 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 1d6b3ee..c55fa6f 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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) } diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 43998c9..2324a2c 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -534,7 +534,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 +543,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 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index a533ea2..665cb9f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } diff --git a/backend/internal/engine/deliver.go b/backend/internal/engine/deliver.go new file mode 100644 index 0000000..36b1c5b --- /dev/null +++ b/backend/internal/engine/deliver.go @@ -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// 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 +} diff --git a/backend/internal/engine/deliver_test.go b/backend/internal/engine/deliver_test.go new file mode 100644 index 0000000..ad3bd76 --- /dev/null +++ b/backend/internal/engine/deliver_test.go @@ -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 +} diff --git a/backend/internal/engine/seedmarker.go b/backend/internal/engine/seedmarker.go index 50514c0..07f5e51 100644 --- a/backend/internal/engine/seedmarker.go +++ b/backend/internal/engine/seedmarker.go @@ -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 diff --git a/backend/internal/game/config.go b/backend/internal/game/config.go index 8d640be..0ae76bc 100644 --- a/backend/internal/game/config.go +++ b/backend/internal/game/config.go @@ -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// 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 diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 7ba61d6..f235b2d 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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 diff --git a/backend/internal/game/version.go b/backend/internal/game/version.go new file mode 100644 index 0000000..215c601 --- /dev/null +++ b/backend/internal/game/version.go @@ -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 +} diff --git a/backend/internal/game/version_test.go b/backend/internal/game/version_test.go new file mode 100644 index 0000000..794b88d --- /dev/null +++ b/backend/internal/game/version_test.go @@ -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 + } +} diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 9e0e1f4..5527741 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -229,21 +229,36 @@ 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) + } + 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 diff --git a/backend/internal/inttest/delete_test.go b/backend/internal/inttest/delete_test.go index 802e0e4..984321c 100644 --- a/backend/internal/inttest/delete_test.go +++ b/backend/internal/inttest/delete_test.go @@ -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) } diff --git a/backend/internal/inttest/dictionary_update_test.go b/backend/internal/inttest/dictionary_update_test.go index c1c359b..1faaee5 100644 --- a/backend/internal/inttest/dictionary_update_test.go +++ b/backend/internal/inttest/dictionary_update_test.go @@ -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. diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go index 9d4235a..2dcd1b3 100644 --- a/backend/internal/inttest/email_test.go +++ b/backend/internal/inttest/email_test.go @@ -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) } diff --git a/backend/internal/inttest/helpers.go b/backend/internal/inttest/helpers.go index 6493cc4..fdf2e60 100644 --- a/backend/internal/inttest/helpers.go +++ b/backend/internal/inttest/helpers.go @@ -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) } diff --git a/backend/internal/inttest/merge_test.go b/backend/internal/inttest/merge_test.go index f306685..0473c1d 100644 --- a/backend/internal/inttest/merge_test.go +++ b/backend/internal/inttest/merge_test.go @@ -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) { diff --git a/backend/internal/inttest/userlist_test.go b/backend/internal/inttest/userlist_test.go index cfb7ac7..2d6df1e 100644 --- a/backend/internal/inttest/userlist_test.go +++ b/backend/internal/inttest/userlist_test.go @@ -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) } diff --git a/backend/internal/link/service.go b/backend/internal/link/service.go index 590e668..04ea562 100644 --- a/backend/internal/link/service.go +++ b/backend/internal/link/service.go @@ -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 diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index e9e073d..a24c6a6 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -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 diff --git a/backend/internal/server/handlers_link.go b/backend/internal/server/handlers_link.go index d8d01dd..2d825ac 100644 --- a/backend/internal/server/handlers_link.go +++ b/backend/internal/server/handlers_link.go @@ -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 { diff --git a/deploy/README.md b/deploy/README.md index 0b72f4a..a9d683e 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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//` 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff21a40..3ea716a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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//` 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** diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1521ed4..1eb9bcf 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 8901aeb..ee61c06 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби** указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен, -🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только +✨ подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только **после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала; запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате. @@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже -принадлежит другому аккаунту, игроку показывают явное **необратимое** -подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает -(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме -случая, когда гость привязывает личность с уже существующим постоянным аккаунтом: -тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние -запрещено, только пока у аккаунтов есть общая незавершённая игра. +принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется, +игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному** +инициатору сначала показывают явное **необратимое** подтверждение — объединение двух +настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец +личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится +**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение +переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть +общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию. В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 0d2c510..8c9bd52 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 7610e07..e4bf934 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -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 } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 6fc60b5..f030405 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -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 } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 021945f..b5a7472 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1451,12 +1451,11 @@ 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 +1735,7 @@ idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock lifts live at the unlock moment. --> {:else} @@ -1746,7 +1745,7 @@ disabled={busy || !isMyTurn || !netReady || hintCount <= 0} onconfirm={doHint} > - 🛟{#if hintCount > 0}{hintCount}{/if} + ✨{#if hintCount > 0}{hintCount}{/if} {t('game.hint')} {/if} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index d57885c..44cce7e 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -662,7 +662,18 @@ async function reconcileServerGuest(): Promise { */ export async function applyLinkResult(r: LinkResult): Promise { 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(); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 81786f2..ef4ed52 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 75871c2..d29fc5d 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -91,7 +91,7 @@ export const ru: Record = { 'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲 'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской 'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄 - 'onboarding.gameHints': 'Доступные подсказки', // 🛟 + 'onboarding.gameHints': 'Доступные подсказки', // ✨ 'onboarding.gameShuffle': 'Встряхните фишки', // 🔀 'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack @@ -312,6 +312,7 @@ export const ru: Record = { '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': 'Вы не участник этой игры.', diff --git a/ui/src/lib/localgame/store.ts b/ui/src/lib/localgame/store.ts index d319869..d10097c 100644 --- a/ui/src/lib/localgame/store.ts +++ b/ui/src/lib/localgame/store.ts @@ -81,6 +81,26 @@ export async function listLocalGames(): Promise { } } +/** 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 { + 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 { const db = openDb();