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 }