feat(ui): тост «Ваш ход» называет предыдущего игрока #62

Merged
developer merged 1 commits from feature/your-turn-toast-opponent-name into development 2026-06-14 20:57:19 +00:00
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 }