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/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_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/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1521ed4..bedad39 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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..13e07e7 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже -принадлежит другому аккаунту, игроку показывают явное **необратимое** -подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает -(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме -случая, когда гость привязывает личность с уже существующим постоянным аккаунтом: -тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние -запрещено, только пока у аккаунтов есть общая незавершённая игра. +принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется, +игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному** +инициатору сначала показывают явное **необратимое** подтверждение — объединение двух +настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец +личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится +**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение +переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть +общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию. В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и 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..2e0c6b5 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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..024491e 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -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();