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}