release: promote development → master (v1.22.0) #274

Merged
developer merged 6 commits from development into master 2026-07-14 23:13:25 +00:00
9 changed files with 127 additions and 27 deletions
Showing only changes of commit da25eac070 - Show all commits
+39 -9
View File
@@ -347,8 +347,10 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
} }
} }
// TestAccountLinkGuestInversion merges a guest initiator into the durable account // TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
// that owns the email: the durable account wins and a fresh session is minted. // 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) { func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
@@ -364,17 +366,18 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err) t.Fatalf("request: %v", err)
} }
code := sixDigit.FindString(mailer.lastBody) 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) t.Fatalf("confirm: %v", err)
} }
merge, err := links.MergeEmail(ctx, guest, email, code) // A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
if err != nil { if !confirm.Merged || confirm.MergeRequired {
t.Fatalf("merge: %v", err) t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
} }
if merge.PrimaryID != durable { if confirm.Merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", 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") t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
} }
if mergedInto(t, guest) != durable { 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 // TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case. // promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) { func TestAccountLinkFreeVK(t *testing.T) {
+32 -6
View File
@@ -31,13 +31,39 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions} return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
} }
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or // ConfirmResult reports the outcome of a confirm step. Exactly one of Linked,
// MergeRequired is set; SecondaryID is the account to be retired when a merge is // MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
// required (the caller renders an irreversible-merge confirmation from it). // 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 { type ConfirmResult struct {
Linked bool Linked bool
MergeRequired bool MergeRequired bool
SecondaryID uuid.UUID 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. // 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{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 // 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 { if owner == callerID {
return ConfirmResult{Linked: true}, nil 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 // 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 { if owner == callerID {
return ConfirmResult{Linked: true}, nil 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 // MergeVK merges the account owning a gateway-validated VK identity into the caller's
+6
View File
@@ -285,6 +285,12 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
// or a completed link (the active account's refreshed profile). // or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse { func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
ctx := c.Request.Context() 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 { if res.MergeRequired {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()} out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil { if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
+9 -6
View File
@@ -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 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 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 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, identity already belongs to another account, the two accounts are merged into one
**irreversible** confirmation and the two accounts are merged into the one they are (statistics summed, games and friends transferred, wallet folded, duplicates removed).
using (statistics summed, games and friends transferred, duplicates removed) — except A **durable** initiator is shown an explicit, **irreversible** confirmation first —
when a guest links an identity that already has a durable account, where the durable consolidating two real accounts is consequential. A **guest** initiator is not: the
account is kept and the guest's games move into it. A merge is blocked only while the durable account that owns the identity is kept, and the ephemeral guest — with its games,
two accounts share a game still in progress. 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 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 Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
+8 -6
View File
@@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое** принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом: настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
запрещено, только пока у аккаунтов есть общая незавершённая игра. **бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
+11
View File
@@ -662,7 +662,18 @@ async function reconcileServerGuest(): Promise<void> {
*/ */
export async function applyLinkResult(r: LinkResult): Promise<void> { export async function applyLinkResult(r: LinkResult): Promise<void> {
if (r.session && r.session.token) { 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); 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; return;
} }
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
+1
View File
@@ -312,6 +312,7 @@ export const en = {
'error.chat_rejected': 'Message rejected (too long or contains contact info).', 'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.", '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.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.chat_already_sent': 'You can send only one message per turn.',
'error.game_finished': 'This game is finished.', 'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.', 'error.not_a_player': 'You are not a player in this game.',
+1
View File
@@ -312,6 +312,7 @@ export const ru: Record<MessageKey, string> = {
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.', 'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.', 'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.merge_active_game_conflict': 'У вас есть незавершённая партия с этим аккаунтом. Доиграйте её и войдите снова.',
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.', 'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
'error.game_finished': 'Эта игра уже завершена.', 'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.', 'error.not_a_player': 'Вы не участник этой игры.',
+20
View File
@@ -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). */ /** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
export async function deleteLocalGame(id: string): Promise<void> { export async function deleteLocalGame(id: string): Promise<void> {
const db = openDb(); const db = openDb();