From 02681ae9e053a4c00704ce7d44d406a6b8ed0ab9 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 20:18:58 +0200 Subject: [PATCH 1/2] feat(chat): limit in-game chat to one message per turn Enforce one chat message per turn on both ends. The backend rejects a second message in the same turn (ErrChatAlreadySentThisTurn -> 409 chat_already_sent), keyed on the move-driven turn start (turn_started_at). The UI derives "already wrote this turn" from the message list against GameView.lastActivityUnix (no counter, survives reopening, resets on turn change), hides the field behind a short caption once the limit is reached, and now reloads the game state on turn/game-state events so the toggle and the limit follow the live game. Enter is gated on !busy to avoid a double-send in the in-flight window. Backend: new game.TurnStartedAt; social GameReader gains it; PostMessage enforces the limit reusing lastMessageAt. UI: new lib/chatlimit.ts pure logic + unit tests; Chat/ChatScreen wiring; chat.sentThisTurn and error.chat_already_sent i18n (en/ru); extended chat e2e. Docs: FUNCTIONAL (+ru), ARCHITECTURE, UI_DESIGN. --- backend/internal/game/service.go | 6 ++++ backend/internal/game/store.go | 13 ++++++++ backend/internal/inttest/social_test.go | 23 ++++++++++++++ backend/internal/server/dto_test.go | 1 + backend/internal/server/handlers.go | 2 ++ backend/internal/social/chat.go | 12 ++++++++ backend/internal/social/social.go | 7 +++++ docs/ARCHITECTURE.md | 5 ++- docs/FUNCTIONAL.md | 4 ++- docs/FUNCTIONAL_ru.md | 4 ++- docs/UI_DESIGN.md | 5 ++- ui/e2e/social.spec.ts | 9 +++++- ui/src/game/Chat.svelte | 22 +++++++++---- ui/src/game/ChatScreen.svelte | 27 +++++++++++++--- ui/src/lib/chatlimit.test.ts | 41 +++++++++++++++++++++++++ ui/src/lib/chatlimit.ts | 37 ++++++++++++++++++++++ ui/src/lib/i18n/en.ts | 2 ++ ui/src/lib/i18n/ru.ts | 2 ++ 18 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 ui/src/lib/chatlimit.test.ts create mode 100644 ui/src/lib/chatlimit.ts diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index fd9d19c..026cc5b 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -408,6 +408,12 @@ func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) return svc.store.LastMoveAt(ctx, gameID, accountID) } +// TurnStartedAt returns the start time of a game's current turn. The social service uses +// it as the per-turn boundary for the one-chat-message-per-turn limit. +func (svc *Service) TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) { + return svc.store.TurnStartedAt(ctx, gameID) +} + // transition validates the actor and turn, applies op under the per-game lock and // commits the result. func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 035d508..4db9f3c 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -968,6 +968,19 @@ func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (ti return at.Time, true, nil } +// TurnStartedAt returns the start time of a game's current turn (games.turn_started_at). +// The social service uses it as the per-turn boundary for the one-chat-message-per-turn +// limit; it advances only on a move, never on chat or a nudge. +func (s *Store) TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) { + var at time.Time + err := s.db.QueryRowContext(ctx, + `SELECT turn_started_at FROM backend.games WHERE game_id = $1`, gameID).Scan(&at) + if err != nil { + return time.Time{}, fmt.Errorf("game: turn started at %s: %w", gameID, err) + } + return at, nil +} + // RobotSchedule returns a game's bag seed and current turn-start time. The admin console // combines them with the robot strategy to show a robot seat's play-to-win intent and its // next-move ETA. Both are server-only state, never part of the public game view. diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 87a13d9..e6e263c 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -333,6 +333,29 @@ func TestChatOnlyOnYourTurn(t *testing.T) { } } +// TestChatOnePerTurn checks the one-message-per-turn limit: a second message on the same +// turn is refused, and a fresh turn (turn_started_at advancing past the sent message) +// reopens chat for the player. +func TestChatOnePerTurn(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the opening + if _, err := svc.PostMessage(ctx, gameID, seats[0], "first", ""); err != nil { + t.Fatalf("first message: %v", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[0], "second", ""); !errors.Is(err, social.ErrChatAlreadySentThisTurn) { + t.Fatalf("second message = %v, want ErrChatAlreadySentThisTurn", err) + } + // Advancing the turn start past the sent message (as a real move does) reopens chat. + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.games SET turn_started_at = now() WHERE game_id = $1`, gameID); err != nil { + t.Fatalf("advance turn: %v", err) + } + if _, err := svc.PostMessage(ctx, gameID, seats[0], "next turn", ""); err != nil { + t.Fatalf("message on the new turn: %v", err) + } +} + func TestNudgeRulesAndRateLimit(t *testing.T) { ctx := context.Background() svc := newSocialService() diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index 06ac282..f000d31 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -25,6 +25,7 @@ func TestStatusForError(t *testing.T) { "nudge own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"}, "nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"}, "chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"}, + "chat one/turn": {social.ErrChatAlreadySentThisTurn, http.StatusConflict, "chat_already_sent"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index b8c83bc..57ac6e3 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -213,6 +213,8 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "nudge_too_soon" case errors.Is(err, social.ErrChatNotYourTurn): return http.StatusConflict, "chat_not_your_turn" + case errors.Is(err, social.ErrChatAlreadySentThisTurn): + return http.StatusConflict, "chat_already_sent" case errors.Is(err, social.ErrSelfRelation): return http.StatusBadRequest, "self_relation" case errors.Is(err, social.ErrRequestExists): diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go index 703acb9..839d192 100644 --- a/backend/internal/social/chat.go +++ b/backend/internal/social/chat.go @@ -65,6 +65,18 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, if idx != toMove { return Message{}, ErrChatNotYourTurn } + // At most one chat message per turn. The turn's start (move-driven, so stable for the + // whole turn) bounds the window: a prior message posted at or after it closes chat until + // the next turn. + turnStart, err := svc.games.TurnStartedAt(ctx, gameID) + if err != nil { + return Message{}, err + } + if last, ok, err := svc.store.lastMessageAt(ctx, gameID, senderID); err != nil { + return Message{}, err + } else if ok && !last.Before(turnStart) { + return Message{}, ErrChatAlreadySentThisTurn + } sender, err := svc.accounts.GetByID(ctx, senderID) if err != nil { return Message{}, err diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index d993b81..0b5c0a2 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -31,6 +31,10 @@ type GameReader interface { // LastMoveAt is the time of an account's most recent move in a game (and whether it // has moved); the nudge cooldown resets once the player has taken a turn. LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) + // TurnStartedAt is the start time of the game's current turn; it bounds the + // one-chat-message-per-turn limit (a message counts toward the current turn when it + // was posted at or after this time). + TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) // GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes // to the game's bot rather than the recipient's last-login bot. GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) @@ -77,6 +81,9 @@ var ( // sender's turn — chat is allowed only on your own turn (the opponent's-turn control // is the nudge). ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn") + // ErrChatAlreadySentThisTurn is returned when the sender has already posted a chat + // message during the current turn — at most one message is allowed per turn. + ErrChatAlreadySentThisTurn = errors.New("social: already sent a chat message this turn") ) // Service is the social domain. It is the only writer of the friendships, blocks diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1032a4a..a4e26f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -444,7 +444,10 @@ English game the Latin pool. - **Chat**: per-game, persisted (kept with the game's archive), **≤ 60 runes**, and **validated on input** — links, email addresses and phone numbers (including lightly obfuscated forms) are rejected, since the chat is for quick reactions, - not contact exchange. Each message stores the sender's IP (forwarded by the + not contact exchange. Chat is allowed **only on the sender's own turn** and + **at most once per turn** (the turn boundary is the move-driven turn start; the + opponent's-turn control is the nudge); the backend enforces both and the client + mirrors them by hiding the field. Each message stores the sender's IP (forwarded by the gateway) for moderation. A sender who has disabled chat cannot post, and messages from a blocked sender are hidden from the viewer. The operator console has a **Messages** section that lists posted messages (nudges excluded) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0287f4d..1637271 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -155,7 +155,9 @@ and/or friend requests — and block individual players (a per-user block hides person's chat and stops requests and game invitations both ways; it also ends any existing friendship). Per-game chat is for quick reactions: messages are short (up to 60 characters) and may not contain links, email addresses or phone numbers, -even disguised. Nudge the player whose turn is awaited at most once per hour (the +even disguised. You may send **one message per turn, on your own turn**; once it is sent +the field gives way to a short caption until your next turn. Nudge the player whose turn +is awaited at most once per hour (the nudge is part of the game chat); the out-of-app push is delivered via the platform. Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c4edff9..3d547d1 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -159,7 +159,9 @@ nudge) приходят от бота **этой партии** — по язы и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны -содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого +содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить +**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего +хода. Nudge ожидаемого соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий push доставляется через платформу. Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index a065711..d69f44a 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -248,7 +248,10 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use - **Finished game**: the board keeps no last-word highlight and no zoom; the history header offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than - hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. + hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input + row is turn-driven: on your turn the message field shows until your one allowed message is + sent, then a short caption replaces it until the next turn; on the opponent's turn the 🛎️ + nudge takes the field's place. ## Caveat diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index f94b0e6..6744c15 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -230,7 +230,7 @@ test('link account: the Telegram web sign-in control is offered in a browser', a await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); }); -test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => { +test('chat: one message per turn — the field shows, then a caption replaces it after sending', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn await page.locator('.scoreboard').click(); // open the history @@ -241,4 +241,11 @@ test('chat: the message field shows on your turn, the nudge replaces it otherwis // through the aria-label. await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); + // The limit is one message per turn: after sending, the field is hidden behind a caption. + // It is still your turn, so the nudge does not appear either. + await page.getByPlaceholder(/Quick message/).fill('hi there'); + await page.getByRole('button', { name: 'Send' }).click(); + await expect(page.getByText('You can write again next turn.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); }); diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 6e689e6..1d88146 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -8,6 +8,7 @@ myId, busy, myTurn = false, + canSend = false, waiting = false, nudgeOnCooldown = false, onsend, @@ -16,11 +17,16 @@ messages: ChatMessage[]; myId: string; busy: boolean; - // Chat and nudge are mutually exclusive by turn: on the player's own turn the - // message field + send are shown (and nudging makes no sense — there is no one to - // hurry); on the opponent's turn only the nudge button shows. While the hourly nudge - // cooldown is active the nudge is disabled with an "awaiting reply" caption. + // The input area has three turn-driven states: on your own turn the message field + + // send show while you may still post (canSend); once the one allowed message is sent a + // short caption replaces them until the next turn; on the opponent's turn only the + // nudge button shows (nudging your own turn makes no sense — there is no one to hurry). + // While the hourly nudge cooldown is active the nudge is disabled with an "awaiting + // reply" caption. myTurn?: boolean; + // canSend is true while the player may still post this turn (own turn and the per-turn + // limit not yet reached); it gates the message field. + canSend?: boolean; // waiting is true while an auto-match game still has no opponent: both send and nudge // are disabled (there is no one to message or hurry yet). waiting?: boolean; @@ -53,14 +59,18 @@ {/each}
- {#if myTurn} + {#if canSend} e.key === 'Enter' && send()} + onkeydown={(e) => e.key === 'Enter' && !busy && send()} /> + {:else if myTurn} + + {t('chat.sentThisTurn')} {:else} {nudgeOnCooldown ? t('chat.awaitingReply') : ''} diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 5f289a4..ba5a527 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -3,6 +3,7 @@ import Chat from './Chat.svelte'; import { gateway } from '../lib/gateway'; import { app, handleError, clearChatUnread } from '../lib/app.svelte'; + import { canSendChat, sentThisTurn } from '../lib/chatlimit'; import type { ChatMessage, StateView } from '../lib/model'; // The Chat tab body, hosted by CommsHub (which supplies the nav bar + tab bar). The @@ -20,6 +21,11 @@ const isMyTurn = $derived( !!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat, ); + // At most one chat message per turn: "already wrote this turn" is derived from the + // message list against the move-driven turn start (lastActivityUnix), so it resets when + // the turn advances. canSend hides the field once the limit is reached (and always on + // the opponent's turn). + const canSend = $derived(canSendChat(isMyTurn, view ? sentThisTurn(messages, myId, view.game.lastActivityUnix) : 0)); // While the auto-match game still has no opponent, chat and nudge are both disabled. const waiting = $derived(!!view && view.game.status === 'open'); const nudgeCooldownSecs = 3600; @@ -46,22 +52,35 @@ /* best-effort */ } } - onMount(async () => { + // loadState (re)reads the game state that drives the chat/nudge toggle and the per-turn + // limit. The initial load surfaces an error; live reloads are best-effort. + async function loadState(initial = false) { try { view = await gateway.gameState(id, false); } catch (e) { - handleError(e); + if (initial) handleError(e); } + } + onMount(async () => { + await loadState(true); await refresh(); }); - // Live: refresh (and keep the unread cleared) on a chat / nudge for this game. + // Live: refresh the message list on a chat / nudge for this game, and reload the state on + // a turn / game-state change so the chat/nudge toggle and the per-turn limit (keyed on the + // turn start) follow the live game. $effect(() => { const e = app.lastEvent; if (!e) return; if ((e.kind === 'chat_message' && e.message.gameId === id) || (e.kind === 'nudge' && e.gameId === id)) { void refresh(); } + if ( + (e.kind === 'your_turn' || e.kind === 'opponent_moved' || e.kind === 'game_over' || e.kind === 'opponent_joined') && + e.gameId === id + ) { + void loadState(); + } }); // Re-evaluate the nudge cooldown on a timer so the control re-enables on time. $effect(() => { @@ -91,4 +110,4 @@ } - + diff --git a/ui/src/lib/chatlimit.test.ts b/ui/src/lib/chatlimit.test.ts new file mode 100644 index 0000000..799bd2b --- /dev/null +++ b/ui/src/lib/chatlimit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { canSendChat, sentThisTurn, turnMessageLimit } from './chatlimit'; +import type { ChatMessage } from './model'; + +function msg(senderId: string, kind: string, createdAtUnix: number): ChatMessage { + return { id: `${senderId}-${kind}-${createdAtUnix}`, gameId: 'g1', senderId, kind, body: kind === 'nudge' ? '' : 'hi', createdAtUnix }; +} + +const ME = 'me'; +const TURN_START = 1000; + +describe('turnMessageLimit', () => { + it('allows one message on your turn and none on the opponent\'s', () => { + expect(turnMessageLimit(true)).toBe(1); + expect(turnMessageLimit(false)).toBe(0); + }); +}); + +describe('sentThisTurn', () => { + it('counts my messages posted at or after the turn start', () => { + const messages = [msg(ME, 'message', TURN_START), msg(ME, 'message', TURN_START + 5)]; + expect(sentThisTurn(messages, ME, TURN_START)).toBe(2); + }); + it('ignores my messages from a previous turn', () => { + expect(sentThisTurn([msg(ME, 'message', TURN_START - 1)], ME, TURN_START)).toBe(0); + }); + it("ignores other players' messages and my own nudges", () => { + const messages = [msg('ann', 'message', TURN_START + 1), msg(ME, 'nudge', TURN_START + 2)]; + expect(sentThisTurn(messages, ME, TURN_START)).toBe(0); + }); +}); + +describe('canSendChat', () => { + it("is false on the opponent's turn", () => { + expect(canSendChat(false, 0)).toBe(false); + }); + it('is true on your turn before sending and false after one message', () => { + expect(canSendChat(true, 0)).toBe(true); + expect(canSendChat(true, 1)).toBe(false); + }); +}); diff --git a/ui/src/lib/chatlimit.ts b/ui/src/lib/chatlimit.ts new file mode 100644 index 0000000..b621396 --- /dev/null +++ b/ui/src/lib/chatlimit.ts @@ -0,0 +1,37 @@ +import type { ChatMessage } from './model'; + +// The in-game chat is a per-turn reaction, not a running conversation: a player may post +// at most one message on their own turn and none on the opponent's. The turn boundary is +// the move-driven turn start (GameView.lastActivityUnix), so "already wrote this turn" is +// derived from the loaded message list rather than a counter — it survives reopening the +// chat and resets by itself when the turn advances. The backend enforces the same rule. + +/** + * turnMessageLimit is how many chat messages a player may post in the given turn state: + * one on the player's own turn, none on the opponent's. + */ +export function turnMessageLimit(isMyTurn: boolean): number { + return isMyTurn ? 1 : 0; +} + +/** + * sentThisTurn counts the chat messages (nudges excluded) that the viewer myId has posted + * since the current turn began. turnStartUnix is the move-driven turn start in Unix + * seconds; a message counts toward the current turn when it was created at or after it. + */ +export function sentThisTurn(messages: ChatMessage[], myId: string, turnStartUnix: number): number { + let n = 0; + for (const m of messages) { + if (m.senderId === myId && m.kind === 'message' && m.createdAtUnix >= turnStartUnix) n += 1; + } + return n; +} + +/** + * canSendChat reports whether the viewer may still post a chat message this turn: the count + * already sent is below the turn's limit. It is false on the opponent's turn (limit zero) + * and once the single own-turn message has been sent. + */ +export function canSendChat(isMyTurn: boolean, sent: number): boolean { + return sent < turnMessageLimit(isMyTurn); +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 984b955..f834b3c 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -113,6 +113,7 @@ export const en = { 'chat.awaitingReply': "Waiting for the opponent's reply", 'chat.empty': 'No messages yet.', 'chat.nudged': '{name} nudged you', + 'chat.sentThisTurn': 'You can write again next turn.', 'profile.title': 'Profile', 'profile.language': 'Language', @@ -177,6 +178,7 @@ export const en = { 'error.chat_rejected': 'Message rejected (too long or contains contact info).', 'error.nudge_too_soon': "Please don't rush your opponent so often.", 'error.chat_not_your_turn': 'You can chat only on your turn.', + 'error.chat_already_sent': 'You can send only one message per turn.', 'error.game_finished': 'This game is finished.', 'error.not_a_player': 'You are not a player in this game.', 'error.already_queued': 'You are already in the queue.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 7425b6d..4ade79d 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -114,6 +114,7 @@ export const ru: Record = { 'chat.awaitingReply': 'Ждём реакцию соперника', 'chat.empty': 'Сообщений пока нет.', 'chat.nudged': '{name} торопит вас', + 'chat.sentThisTurn': 'Можно написать снова в следующем ходу.', 'profile.title': 'Профиль', 'profile.language': 'Язык', @@ -178,6 +179,7 @@ export const ru: Record = { 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.nudge_too_soon': 'Не стоит торопить соперника так часто.', 'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.', + 'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.', 'error.game_finished': 'Эта игра уже завершена.', 'error.not_a_player': 'Вы не участник этой игры.', 'error.already_queued': 'Вы уже в очереди.', -- 2.52.0 From 5f53ec81b9df5195c4fc09705b1e23f44b1243f0 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sun, 14 Jun 2026 20:29:47 +0200 Subject: [PATCH 2/2] fix(chat): no chat field or nudge on a finished game MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A finished game is read-only: gate the nudge behind canNudge (active game, opponent's turn) so it no longer shows on a finished (or otherwise non-active) game — where the backend rejects it anyway. The message field is already hidden off your own turn. Extend the finished-game e2e to assert neither Send nor Nudge is offered. --- ui/e2e/social.spec.ts | 3 +++ ui/src/game/Chat.svelte | 17 ++++++++++------- ui/src/game/ChatScreen.svelte | 5 ++++- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 6744c15..64185ea 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -116,6 +116,9 @@ test('finished game draws an inert footer and trims live-only controls', async ( await page.getByRole('button', { name: 'Chat' }).click(); // 💬 await expect(page.locator('.pane')).toHaveCount(1); await expect(page.getByRole('button', { name: 'Dictionary' })).toHaveCount(0); + // A finished game is read-only: neither the message field nor the nudge is offered. + await expect(page.getByRole('button', { name: 'Send' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); }); test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => { diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index 1d88146..1b47305 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -9,6 +9,7 @@ busy, myTurn = false, canSend = false, + canNudge = false, waiting = false, nudgeOnCooldown = false, onsend, @@ -17,16 +18,18 @@ messages: ChatMessage[]; myId: string; busy: boolean; - // The input area has three turn-driven states: on your own turn the message field + - // send show while you may still post (canSend); once the one allowed message is sent a - // short caption replaces them until the next turn; on the opponent's turn only the - // nudge button shows (nudging your own turn makes no sense — there is no one to hurry). - // While the hourly nudge cooldown is active the nudge is disabled with an "awaiting - // reply" caption. + // The input area is turn-driven, in priority order: while you may still post (canSend) + // the message field + send show; once your one allowed message is sent a short caption + // replaces them until the next turn (myTurn); on the opponent's turn of an active game + // the nudge button shows (canNudge) — disabled with an "awaiting reply" caption while the + // hourly cooldown is active; a finished game offers nothing (it is read-only). myTurn?: boolean; // canSend is true while the player may still post this turn (own turn and the per-turn // limit not yet reached); it gates the message field. canSend?: boolean; + // canNudge is true on the opponent's turn of an active game; it gates the nudge button + // (nudging your own turn or a finished game makes no sense). + canNudge?: boolean; // waiting is true while an auto-match game still has no opponent: both send and nudge // are disabled (there is no one to message or hurry yet). waiting?: boolean; @@ -71,7 +74,7 @@ {t('chat.sentThisTurn')} - {:else} + {:else if canNudge} {nudgeOnCooldown ? t('chat.awaitingReply') : ''} diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index ba5a527..551d045 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -26,6 +26,9 @@ // the turn advances. canSend hides the field once the limit is reached (and always on // the opponent's turn). const canSend = $derived(canSendChat(isMyTurn, view ? sentThisTurn(messages, myId, view.game.lastActivityUnix) : 0)); + // The nudge is offered only on the opponent's turn of an active game (you hurry the player + // who is to move). A finished game is read-only: neither chat nor nudge is offered. + const canNudge = $derived(!!view && view.game.status === 'active' && view.game.toMove !== view.seat); // While the auto-match game still has no opponent, chat and nudge are both disabled. const waiting = $derived(!!view && view.game.status === 'open'); const nudgeCooldownSecs = 3600; @@ -110,4 +113,4 @@ } - + -- 2.52.0