feat(ui): name the previous player in the your-turn toast
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 58s

The in-app "your turn" toast (shown when it becomes your turn in another
game) now reads "<Opponent>: 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.
This commit is contained in:
Ilia Denisov
2026-06-14 22:52:15 +02:00
parent a80952a835
commit ac62d29ef7
9 changed files with 48 additions and 5 deletions
+4 -1
View File
@@ -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
+33
View File
@@ -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');
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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();
});
});
+5
View File
@@ -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('пас');
+1
View File
@@ -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}",
+1
View File
@@ -62,6 +62,7 @@ export const ru: Record<MessageKey, string> = {
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход',
'game.yourTurnBy': '{name}: Ваш ход!',
'game.opponentsTurn': 'Ход соперника',
'game.searchingForOpponent': 'Поиск соперника...',
'game.waiting': 'Ожидаем {name}',
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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 }