From ac62d29ef738271112b49a82759eabcae6dc4e07 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 22:52:15 +0200 Subject: [PATCH] feat(ui): name the previous player in the your-turn toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-app "your turn" toast (shown when it becomes your turn in another game) now reads ": Your turn!", naming the player who moved just before this one — the previous seat in turn order, so it reads the same in games with any number of players. Falls back to the bare "Your turn" label when no name is present (an older peer, or a gap event without one). YourTurnEvent already carries opponent_name end to end: the backend sets it (game.displayName of the last mover) and the gateway forwards the payload opaquely, so this is a client-only change — decode the field, thread it through PushEvent, and pick the localized string. --- ui/src/lib/app.svelte.ts | 5 ++++- ui/src/lib/codec.test.ts | 33 +++++++++++++++++++++++++++++++++ ui/src/lib/codec.ts | 2 +- ui/src/lib/gamedelta.test.ts | 2 +- ui/src/lib/i18n.test.ts | 5 +++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/mock/client.ts | 2 +- ui/src/lib/model.ts | 2 +- 9 files changed, 48 insertions(+), 5 deletions(-) diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index a53ad61..8b219ae 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -210,7 +210,10 @@ function openStream(): void { } else if (e.kind === 'nudge') { showToast(t('chat.nudge'), 'info'); } else if (e.kind === 'your_turn') { - showToast(t('game.yourTurn'), 'info'); + // Name the player who moved just before this one (the previous seat in turn order), so the + // toast reads the same in games with any number of players. Fall back to the bare label when + // no name is present (an older peer, or a gap event without one). + showToast(e.opponentName ? t('game.yourTurnBy', { name: e.opponentName }) : t('game.yourTurn'), 'info'); } else if (e.kind === 'opponent_moved' || e.kind === 'game_over') { // Keep both caches fresh from any screen, so neither the lobby nor the game flashes a stale // frame on the next navigation. The lobby snapshot is patched from the event's own GameView diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 5717c63..af3e8c0 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -300,6 +300,39 @@ describe('codec', () => { expect(inv.variant).toBe('scrabble_en'); }); + it('decodes a your_turn event, carrying the previous player name for the toast', () => { + const b = new Builder(64); + const gid = b.createString('g-1'); + const name = b.createString('Ann'); + fb.YourTurnEvent.startYourTurnEvent(b); + fb.YourTurnEvent.addGameId(b, gid); + fb.YourTurnEvent.addOpponentName(b, name); + fb.YourTurnEvent.addMoveCount(b, 7); + b.finish(fb.YourTurnEvent.endYourTurnEvent(b)); + expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({ + kind: 'your_turn', + gameId: 'g-1', + deadlineUnix: 0, + opponentName: 'Ann', + moveCount: 7, + }); + }); + + it('decodes a your_turn event with no opponent name as an empty string', () => { + const b = new Builder(64); + const gid = b.createString('g-2'); + fb.YourTurnEvent.startYourTurnEvent(b); + fb.YourTurnEvent.addGameId(b, gid); + b.finish(fb.YourTurnEvent.endYourTurnEvent(b)); + expect(decodeEvent('your_turn', b.asUint8Array())).toEqual({ + kind: 'your_turn', + gameId: 'g-2', + deadlineUnix: 0, + opponentName: '', + moveCount: 0, + }); + }); + it('decodes an opponent_joined event (reusing the match_found payload layout)', () => { const b = new Builder(64); const gid = b.createString('g-open'); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 5d7e29d..aac290f 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -442,7 +442,7 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null switch (kind) { case 'your_turn': { const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb); - return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), moveCount: e.moveCount() }; + return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()), opponentName: s(e.opponentName()), moveCount: e.moveCount() }; } case 'opponent_moved': { const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb); diff --git a/ui/src/lib/gamedelta.test.ts b/ui/src/lib/gamedelta.test.ts index f31f9d4..57318e7 100644 --- a/ui/src/lib/gamedelta.test.ts +++ b/ui/src/lib/gamedelta.test.ts @@ -137,7 +137,7 @@ describe('advanceCached', () => { }); it('ignores event kinds that do not advance a game board', () => { - const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, moveCount: 4 }; + const yourTurn: PushEvent = { kind: 'your_turn', gameId: 'g1', deadlineUnix: 0, opponentName: 'Ann', moveCount: 4 }; expect(advanceCached(cache(3, 0), yourTurn)).toBeUndefined(); }); }); diff --git a/ui/src/lib/i18n.test.ts b/ui/src/lib/i18n.test.ts index ae65a56..fd41b77 100644 --- a/ui/src/lib/i18n.test.ts +++ b/ui/src/lib/i18n.test.ts @@ -13,6 +13,11 @@ describe('i18n catalog', () => { expect(translate('ru', 'game.bag', { n: 7 })).toBe('7 в мешке'); }); + it('names the previous player in the cross-game your-turn toast', () => { + expect(translate('ru', 'game.yourTurnBy', { name: 'Аня' })).toBe('Аня: Ваш ход!'); + expect(translate('en', 'game.yourTurnBy', { name: 'Ann' })).toBe('Ann: Your turn!'); + }); + it('localizes move-history action labels', () => { expect(translate('ru', 'move.exchange')).toBe('обмен'); expect(translate('ru', 'move.pass')).toBe('пас'); diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 3c86897..5d38e95 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -61,6 +61,7 @@ export const en = { 'game.bagEmpty': 'Bag is empty', 'game.hints': 'Hints {n}', 'game.yourTurn': 'Your turn', + 'game.yourTurnBy': '{name}: Your turn!', 'game.opponentsTurn': "Opponent's turn", 'game.searchingForOpponent': 'Searching for opponent…', 'game.waiting': "Waiting for {name}", diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 925fd80..93ae48b 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -62,6 +62,7 @@ export const ru: Record = { 'game.bagEmpty': 'Мешок пуст', 'game.hints': 'Подсказки {n}', 'game.yourTurn': 'Ваш ход', + 'game.yourTurnBy': '{name}: Ваш ход!', 'game.opponentsTurn': 'Ход соперника', 'game.searchingForOpponent': 'Поиск соперника...', 'game.waiting': 'Ожидаем {name}', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 3f86e52..964e251 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -598,7 +598,7 @@ export class MockGateway implements GatewayClient { g.view.moveCount += 1; g.view.toMove = this.mySeat(g); this.emit({ kind: 'opponent_moved', gameId, move: structuredClone(move), game: structuredClone(g.view), bagLen: g.bagLen }); - this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, moveCount: g.view.moveCount }); + this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400, opponentName: g.view.seats[opp].displayName, moveCount: g.view.moveCount }); }, 1600); } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 806a334..eb16c78 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -255,7 +255,7 @@ export interface GameList { * so a client falls back to a refetch when a payload is absent (a gap, or an older peer). */ export type PushEvent = - | { kind: 'your_turn'; gameId: string; deadlineUnix: number; moveCount: number } + | { kind: 'your_turn'; gameId: string; deadlineUnix: number; opponentName: string; moveCount: number } | { kind: 'opponent_moved'; gameId: string; move?: MoveRecord; game?: GameView; bagLen: number } | { kind: 'game_over'; gameId: string; result: string; scoreLine: string; game?: GameView } | { kind: 'chat_message'; message: ChatMessage }