native polish: guest-locale banner, seamless guest sign-in (+local-game fix), 'Opponent's turn' label, deploy-authoritative dictionary #273
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -285,6 +285,12 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
|
||||
// or a completed link (the active account's refreshed profile).
|
||||
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
|
||||
ctx := c.Request.Context()
|
||||
if res.Merged {
|
||||
// A guest initiator's merge ran inline (the guest-primary rule; no confirmation step),
|
||||
// so render the completed merge — the client adopts the switched session and lands on
|
||||
// the surviving durable account as if it simply logged in.
|
||||
return s.mergeResultResponse(c, res.Merge)
|
||||
}
|
||||
if res.MergeRequired {
|
||||
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
|
||||
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
|
||||
|
||||
+9
-6
@@ -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
|
||||
|
||||
@@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль
|
||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
||||
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
|
||||
принадлежит другому аккаунту, игроку показывают явное **необратимое**
|
||||
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
|
||||
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
|
||||
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
|
||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
|
||||
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
|
||||
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
|
||||
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
|
||||
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
|
||||
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
|
||||
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
|
||||
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
|
||||
|
||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||
|
||||
@@ -662,7 +662,18 @@ async function reconcileServerGuest(): Promise<void> {
|
||||
*/
|
||||
export async function applyLinkResult(r: LinkResult): Promise<void> {
|
||||
if (r.session && r.session.token) {
|
||||
// A guest initiator's merge switches the session to the surviving durable account. Its
|
||||
// device-local games (vs_ai / hotseat) were seated under the retired account's id, so
|
||||
// re-point them to the survivor: the lobby and the game header identify "me" by the active
|
||||
// account, and without this the merged-away game shows every seat as an opponent (the
|
||||
// in-game turn logic is seat-index based, so it still plays — the summary just misreads).
|
||||
const retiredId = app.session?.userId;
|
||||
await adoptSession(r.session);
|
||||
const survivorId = app.session?.userId;
|
||||
if (retiredId && survivorId && retiredId !== survivorId) {
|
||||
const { repointLocalGameSeats } = await import('./localgame/store');
|
||||
await repointLocalGameSeats(retiredId, survivorId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
app.profile = await gateway.profileGet();
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -312,6 +312,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
|
||||
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
|
||||
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
|
||||
'error.merge_active_game_conflict': 'У вас есть незавершённая партия с этим аккаунтом. Доиграйте её и войдите снова.',
|
||||
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
|
||||
'error.game_finished': 'Эта игра уже завершена.',
|
||||
'error.not_a_player': 'Вы не участник этой игры.',
|
||||
|
||||
@@ -81,6 +81,26 @@ export async function listLocalGames(): Promise<LocalGameRecord[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** repointLocalGameSeats rewrites the account id on every stored game's seats from oldId to
|
||||
* newId. It is run when an account merge retires the account a device-local game was seated
|
||||
* under (a guest that logged into a durable account): the surviving account then owns the
|
||||
* games, so the lobby and the game header identify "me" again — without it the human seat no
|
||||
* longer matches the active account and the game shows every seat as an opponent. Best-effort;
|
||||
* the records read from IndexedDB are plain objects, so mutating and re-saving them is safe. */
|
||||
export async function repointLocalGameSeats(oldId: string, newId: string): Promise<void> {
|
||||
if (!oldId || !newId || oldId === newId) return;
|
||||
for (const g of await listLocalGames()) {
|
||||
let touched = false;
|
||||
for (const s of g.seats) {
|
||||
if (s.accountId === oldId) {
|
||||
s.accountId = newId;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
if (touched) await saveLocalGame(g);
|
||||
}
|
||||
}
|
||||
|
||||
/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
|
||||
export async function deleteLocalGame(id: string): Promise<void> {
|
||||
const db = openDb();
|
||||
|
||||
Reference in New Issue
Block a user