feat(chat): limit in-game chat to one message per turn
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m8s

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.
This commit is contained in:
Ilia Denisov
2026-06-14 20:18:58 +02:00
parent fc28e43e43
commit 02681ae9e0
18 changed files with 207 additions and 15 deletions
+6
View File
@@ -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) {
+13
View File
@@ -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.
+23
View File
@@ -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()
+1
View File
@@ -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"},
+2
View File
@@ -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):
+12
View File
@@ -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
+7
View File
@@ -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
+4 -1
View File
@@ -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)
+3 -1
View File
@@ -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
+3 -1
View File
@@ -159,7 +159,9 @@ nudge) приходят от бота **этой партии** — по язы
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
хода. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу.
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
+4 -1
View File
@@ -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
+8 -1
View File
@@ -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);
});
+16 -6
View File
@@ -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}
</div>
<div class="input">
{#if myTurn}
{#if canSend}
<input
maxlength="60"
placeholder={t('chat.placeholder')}
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
onkeydown={(e) => e.key === 'Enter' && !busy && send()}
/>
<button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button>
{:else if myTurn}
<!-- Own turn, the one allowed message already sent: a caption holds the row until the
next turn (no nudge — there is no one to hurry on your own turn). -->
<span class="cooldown">{t('chat.sentThisTurn')}</span>
{:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
+23 -4
View File
@@ -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 @@
}
</script>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {canSend} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
+41
View File
@@ -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);
});
});
+37
View File
@@ -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);
}
+2
View File
@@ -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.',
+2
View File
@@ -114,6 +114,7 @@ export const ru: Record<MessageKey, string> = {
'chat.awaitingReply': 'Ждём реакцию соперника',
'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас',
'chat.sentThisTurn': 'Можно написать снова в следующем ходу.',
'profile.title': 'Профиль',
'profile.language': 'Язык',
@@ -178,6 +179,7 @@ export const ru: Record<MessageKey, string> = {
'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': 'Вы уже в очереди.',