Compare commits

..

31 Commits

Author SHA1 Message Date
developer e836dfefba Merge pull request 'release: promote development → master (v1.21.0)' (#272) from development into master 2026-07-14 20:29:04 +00:00
developer 39e03d22e4 Merge pull request 'release: v1.20.1 — real client IP to backend (last-login fix) + host UTC' (#265) from development into master 2026-07-14 09:44:43 +00:00
developer aaf162de40 Merge pull request 'release: v1.20.0 — offline-UX, catalog order + admin toggles, reward-config admin' (#263) from development into master 2026-07-14 09:02:54 +00:00
developer 5ce9f7e9b4 Merge pull request 'chore(release): promote development to master' (#258) from development into master 2026-07-13 22:21:59 +00:00
developer 3456a0b3ff Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master 2026-07-13 07:39:06 +00:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
32 changed files with 95 additions and 521 deletions
+2 -7
View File
@@ -49,14 +49,9 @@ 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 (ARCHITECTURE.md §5).
# 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).
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"]
+4 -11
View File
@@ -111,12 +111,6 @@ 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)
@@ -124,7 +118,6 @@ 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
@@ -154,10 +147,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 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).
// 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).
if err := games.InitActiveVersion(ctx); err != nil {
return fmt.Errorf("init active dictionary version: %w", err)
}
+3 -11
View File
@@ -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, language string) (Account, error) {
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err)
@@ -543,17 +543,9 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
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, table.Accounts.PreferredLanguage).
VALUES(accountID, guestDisplayName, true, tz, lang).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true, tz).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
-1
View File
@@ -106,7 +106,6 @@ func Load() (Config, error) {
gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
return Config{}, err
}
-92
View File
@@ -1,92 +0,0 @@
package engine
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// DeliverVersion makes the build's dictionary version resident on the persistent
// dictionary volume without disturbing the versions in-flight games already pin.
//
// The volume is seeded from the image once and never re-seeded, and the image's
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
// copies it into dictDir/<version>/ when the version is not already present —
// neither the flat seed's own recorded label nor an existing version
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
// uploads are never touched, so old games keep the version they pin while the new
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
func DeliverVersion(dictDir, seedDir, version string) error {
if seedDir == "" || version == "" {
return nil
}
target := filepath.Join(dictDir, version)
if _, err := os.Stat(target); err == nil {
return nil // already delivered on a prior boot
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
}
// A fresh volume is populated from the image and recorded as this version by the
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
// records the label on first use, matching OpenWithVersions.
seed, err := resolveSeedVersion(dictDir, version)
if err != nil {
return err
}
if seed == version {
return nil
}
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
if err := os.RemoveAll(staging); err != nil {
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
}
if err := os.MkdirAll(staging, 0o755); err != nil {
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
}
for _, file := range dictFiles {
src := filepath.Join(seedDir, file)
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
continue // a variant absent from the seed is simply absent under this version
} else if err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
}
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
_ = os.RemoveAll(staging)
return err
}
}
if err := os.Rename(staging, target); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
}
return nil
}
// copyFile copies a single file, truncating the destination.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("engine: open %s: %w", src, err)
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("engine: create %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("engine: finalise %s: %w", dst, err)
}
return nil
}
-68
View File
@@ -1,68 +0,0 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestDeliverVersion(t *testing.T) {
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
t.Fatalf("blank seedDir: %v", err)
}
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
dict := t.TempDir()
seed := t.TempDir()
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, f := range dictFiles {
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
t.Fatal(err)
}
}
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("deliver v1.3.1: %v", err)
}
for _, f := range dictFiles {
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
if err != nil {
t.Fatalf("delivered %s: %v", f, err)
}
if string(got) != "dawg:"+f {
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
}
}
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
}
// Idempotent: a second call is a no-op and leaves no staging directory behind.
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("re-deliver v1.3.1: %v", err)
}
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
t.Errorf("a staging directory survived delivery")
}
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
// as a redundant subdirectory.
fresh := t.TempDir()
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
t.Fatalf("fresh deliver: %v", err)
}
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
}
}
func hasStaging(entries []os.DirEntry) bool {
for _, e := range entries {
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
return true
}
}
return false
}
+5 -5
View File
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion.
//
// So 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.
// 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.
//
// A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is
-7
View File
@@ -18,13 +18,6 @@ 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
+9 -19
View File
@@ -232,31 +232,21 @@ func (svc *Service) ActiveVersion() string {
}
// InitActiveVersion reconciles the persisted active dictionary version with the
// 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.
// 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.
func (svc *Service) InitActiveVersion(ctx context.Context) error {
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
if err != nil {
return err
}
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
if ok && svc.versionResident(persisted) {
svc.verMu.Lock()
svc.version = persisted
svc.verMu.Unlock()
return nil
}
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)
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
}
// SetActiveVersion records version as the active dictionary version, persisting it
-45
View File
@@ -1,45 +0,0 @@
package game
import (
"strconv"
"strings"
)
// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH"
// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels
// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed
// or malformed pair never crashes the boot-time active-version reconcile).
func compareDictVersions(a, b string) int {
pa, oka := parseDictVersion(a)
pb, okb := parseDictVersion(b)
if !oka || !okb {
return strings.Compare(a, b)
}
for i := range pa {
if pa[i] != pb[i] {
if pa[i] < pb[i] {
return -1
}
return 1
}
}
return 0
}
// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting
// whether it matched that shape.
func parseDictVersion(v string) ([3]int, bool) {
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
-37
View File
@@ -1,37 +0,0 @@
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
}
}
+3 -18
View File
@@ -229,36 +229,21 @@ 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 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")
// A guest is seeded its detected offset; an empty one keeps the UTC default.
guest, err := store.ProvisionGuest(ctx, "-05:30")
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)
}
if guest.PreferredLanguage != "ru" {
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
}
plainGuest, err := store.ProvisionGuest(ctx, "", "")
plainGuest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision plain guest: %v", err)
}
if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
}
if plainGuest.PreferredLanguage != "en" {
t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage)
}
// An unsupported locale falls back to the 'en' default rather than persisting a junk value.
frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR")
if err != nil {
t.Fatalf("provision fr guest: %v", err)
}
if frGuest.PreferredLanguage != "en" {
t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage)
}
}
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
+1 -1
View File
@@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) {
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "", "")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -103,31 +103,14 @@ func TestDictionaryUpdateFlow(t *testing.T) {
t.Errorf("installed dawg missing on disk: %v", err)
}
// 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).
// The choice survives a restart: a fresh service over the same DB and registry
// re-adopts the persisted active version.
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 (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)
t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
}
// New games pin the new version; the in-progress game keeps its own, still resident.
+1 -1
View File
@@ -431,7 +431,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "", "")
guest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+1 -1
View File
@@ -154,7 +154,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "")
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+9 -39
View File
@@ -347,10 +347,8 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
}
}
// 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).
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
// that owns the email: the durable account wins and a fresh session is minted.
func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -366,18 +364,17 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
if err != nil {
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
t.Fatalf("confirm: %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)
merge, err := links.MergeEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
}
if confirm.Merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
}
if confirm.Merge.SwitchedToken == "" {
if merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
@@ -388,33 +385,6 @@ func TestAccountLinkGuestInversion(t *testing.T) {
}
}
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "conflict-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
}
if mergedInto(t, guest) != uuid.Nil {
t.Error("a refused auto-merge must not tombstone the guest")
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
+1 -1
View File
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx, "", "")
guest, err := st.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+6 -32
View File
@@ -31,39 +31,13 @@ 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,
// 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).
// 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).
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.
@@ -93,7 +67,7 @@ func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email,
}
return ConfirmResult{Linked: true}, nil
}
return s.confirmOrAutoMerge(ctx, accountID, owner)
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeEmail re-verifies the code and merges the address's account into the
@@ -129,7 +103,7 @@ func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, exter
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return s.confirmOrAutoMerge(ctx, callerID, owner)
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeTelegram merges the account owning a gateway-validated Telegram identity
@@ -176,7 +150,7 @@ func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return s.confirmOrAutoMerge(ctx, callerID, owner)
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
+4 -8
View File
@@ -160,20 +160,16 @@ 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 and its interface
// language from the optional detected locale.
// seeding its time zone from the optional detected browser offset.
func (s *Server) handleGuestAuth(c *gin.Context) {
// 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.
// 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.
var req guestAuthRequest
_ = c.ShouldBindJSON(&req)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ, req.Language)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
if err != nil {
s.abortErr(c, err)
return
-6
View File
@@ -285,12 +285,6 @@ 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 {
+5 -9
View File
@@ -98,7 +98,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image (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. |
| `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. |
| `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,14 +176,10 @@ 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 `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).
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).
## Production rollout
+16 -21
View File
@@ -446,27 +446,22 @@ 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, 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.)
`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.)
- 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**
+6 -9
View File
@@ -117,15 +117,12 @@ 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 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.
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.
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
+6 -8
View File
@@ -121,14 +121,12 @@ Telegram держит **единого бота**: все игроки поль
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
+3 -4
View File
@@ -312,12 +312,11 @@ 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) 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) {
// from browserTz (the client's detected "±HH:MM" UTC offset).
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype 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, "language": language}, &out)
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
return out, err
}
+2 -3
View File
@@ -270,14 +270,13 @@ 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, locale string
var browserTz, subtype 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, locale)
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
if err != nil {
return nil, err
}
+5 -4
View File
@@ -1451,11 +1451,12 @@
return s.displayName;
}
// 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.
// 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).
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');
}
-11
View File
@@ -662,18 +662,7 @@ 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();
-1
View File
@@ -312,7 +312,6 @@ 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.',
-1
View File
@@ -312,7 +312,6 @@ export const ru: Record<MessageKey, string> = {
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.merge_active_game_conflict': 'У вас есть незавершённая партия с этим аккаунтом. Доиграйте её и войдите снова.',
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
-20
View File
@@ -81,26 +81,6 @@ 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();