From 6d665450621506597e9cdcf18dfea91c6e1749e5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 16 Jun 2026 15:22:27 +0200 Subject: [PATCH] fix(robot): show the per-game name in REST game views, not the account name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat display-name snapshot only reached the live-event path (seatNames); the REST DTO layer still resolved seat names from the account store (fillSeatNames), so the game screen and lobby list showed the robot's seeded account name ("Женя") while the your_turn toast showed its per-game name ("Звёздный_Барс2"). Carry the snapshot into gameDTOFromGame and have fillSeatNames fall back to the account only for a seat with no snapshot (a pre-snapshot legacy row). Friends and invitations keep account names (the persistent identity, not a per-game disguise). --- backend/internal/server/dto.go | 23 ++++++++++------- backend/internal/server/dto_test.go | 33 ++++++++++++++++++++++++ backend/internal/server/handlers_game.go | 9 +++++-- 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index b73c3b5..7f1aa2f 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -77,8 +77,9 @@ type moveRecordDTO struct { Total int `json:"total"` } -// seatDTO is one seat's public standing. DisplayName is resolved from the account -// store by the handler (the game domain keys seats by account id only). +// seatDTO is one seat's public standing. DisplayName is the seat's display-name snapshot +// (the per-game name, frozen when the seat was taken); fillSeatNames fills it from the +// account store only when a seat has no snapshot yet (a pre-snapshot legacy row). type seatDTO struct { Seat int `json:"seat"` AccountID string `json:"account_id"` @@ -199,19 +200,21 @@ const awayTimeLayout = "15:04" func gameDTOFromGame(g game.Game) gameDTO { seats := make([]seatDTO, 0, len(g.Seats)) for _, s := range g.Seats { - // An open game's still-empty opponent seat has no account: emit an empty id (the - // display name is left empty by fillSeatNames) so the client shows "searching for - // opponent" rather than the nil-UUID. + // An open game's still-empty opponent seat has no account: emit an empty id (and an + // empty display name) so the client shows "searching for opponent" rather than the + // nil-UUID. DisplayName carries the seat's per-game snapshot; fillSeatNames fills + // only the seats still without one from the account store. accountID := "" if s.AccountID != uuid.Nil { accountID = s.AccountID.String() } seats = append(seats, seatDTO{ - Seat: s.Seat, - AccountID: accountID, - Score: s.Score, - HintsUsed: s.HintsUsed, - IsWinner: s.IsWinner, + Seat: s.Seat, + AccountID: accountID, + DisplayName: s.DisplayName, + Score: s.Score, + HintsUsed: s.HintsUsed, + IsWinner: s.IsWinner, }) } last := g.TurnStartedAt diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index f000d31..6967717 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -1,6 +1,7 @@ package server import ( + "context" "net/http" "testing" "time" @@ -111,3 +112,35 @@ func TestMoveRecordDTOFrom(t *testing.T) { t.Fatalf("move dto mismatch: %+v", dto) } } + +// TestGameDTOCarriesSeatSnapshot checks the DTO carries each seat's display-name +// snapshot (so a disguised robot's per-game name reaches the REST views, not just live +// events) rather than leaving it for an account lookup. +func TestGameDTOCarriesSeatSnapshot(t *testing.T) { + g := game.Game{ + Variant: engine.VariantEnglish, Players: 2, TurnTimeout: time.Hour, + Seats: []game.Seat{ + {Seat: 0, AccountID: uuid.New(), DisplayName: "Звёздный_Барс2"}, + {Seat: 1, AccountID: uuid.New(), DisplayName: "Тест"}, + }, + } + dto := gameDTOFromGame(g) + if dto.Seats[0].DisplayName != "Звёздный_Барс2" || dto.Seats[1].DisplayName != "Тест" { + t.Fatalf("seat snapshot not carried into DTO: %+v", dto.Seats) + } +} + +// TestFillSeatNamesKeepsSnapshot checks fillSeatNames leaves a seat whose snapshot is +// already set untouched (no account lookup), so the per-game name is not overwritten by +// the robot's seeded account name. +func TestFillSeatNamesKeepsSnapshot(t *testing.T) { + s := &Server{} // accounts is unused: a present snapshot must skip the lookup + dto := &gameDTO{Seats: []seatDTO{ + {Seat: 0, AccountID: uuid.New().String(), DisplayName: "Звёздный_Барс2"}, + {Seat: 1, AccountID: uuid.New().String(), DisplayName: "Тест"}, + }} + s.fillSeatNames(context.Background(), dto, map[string]string{}) + if dto.Seats[0].DisplayName != "Звёздный_Барс2" || dto.Seats[1].DisplayName != "Тест" { + t.Fatalf("fillSeatNames overwrote a seat snapshot: %+v", dto.Seats) + } +} diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index ba9158f..4791eff 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -66,10 +66,15 @@ type complaintRequest struct { Note string `json:"note"` } -// fillSeatNames resolves each seat's display name from the account store, memoising -// across seats and games within one request. +// fillSeatNames fills each seat's display name from the account store — memoising across +// seats and games within one request — for the seats that carry no per-game snapshot yet +// (a pre-snapshot legacy row). A seat whose snapshot is already set is left untouched, so +// a disguised robot keeps its per-game name rather than reverting to its account name. func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) { for i := range g.Seats { + if g.Seats[i].DisplayName != "" { + continue // the seat already carries its per-game snapshot + } id := g.Seats[i].AccountID name, ok := memo[id] if !ok {