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) 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 // transition validates the actor and turn, applies op under the per-game lock and
// commits the result. // commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) { 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 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 // 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 // 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. // 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) { func TestNudgeRulesAndRateLimit(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newSocialService() 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 own turn": {social.ErrNudgeOnOwnTurn, http.StatusConflict, "nudge_own_turn"},
"nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"}, "nudge too soon": {social.ErrNudgeTooSoon, http.StatusConflict, "nudge_too_soon"},
"chat off turn": {social.ErrChatNotYourTurn, http.StatusConflict, "chat_not_your_turn"}, "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"}, "illegal play": {engine.ErrIllegalPlay, http.StatusUnprocessableEntity, "illegal_play"},
"email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"}, "email taken": {account.ErrEmailTaken, http.StatusConflict, "email_taken"},
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, "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" return http.StatusConflict, "nudge_too_soon"
case errors.Is(err, social.ErrChatNotYourTurn): case errors.Is(err, social.ErrChatNotYourTurn):
return http.StatusConflict, "chat_not_your_turn" 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): case errors.Is(err, social.ErrSelfRelation):
return http.StatusBadRequest, "self_relation" return http.StatusBadRequest, "self_relation"
case errors.Is(err, social.ErrRequestExists): 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 { if idx != toMove {
return Message{}, ErrChatNotYourTurn 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) sender, err := svc.accounts.GetByID(ctx, senderID)
if err != nil { if err != nil {
return Message{}, err 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 // 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. // 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) 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 // 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. // to the game's bot rather than the recipient's last-login bot.
GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) 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 // sender's turn — chat is allowed only on your own turn (the opponent's-turn control
// is the nudge). // is the nudge).
ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn") 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 // 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**, - **Chat**: per-game, persisted (kept with the game's archive), **≤ 60 runes**,
and **validated on input** — links, email addresses and phone numbers (including and **validated on input** — links, email addresses and phone numbers (including
lightly obfuscated forms) are rejected, since the chat is for quick reactions, 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, 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 and messages from a blocked sender are hidden from the viewer. The operator console
has a **Messages** section that lists posted messages (nudges excluded) 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 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 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, (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. 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** 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 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 символов) и не должны партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
хода. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу. 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 - **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 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 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 ## 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(); 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 loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
await page.locator('.scoreboard').click(); // open the history 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. // through the aria-label.
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Send' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Nudge' })).toHaveCount(0); 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, myId,
busy, busy,
myTurn = false, myTurn = false,
canSend = false,
waiting = false, waiting = false,
nudgeOnCooldown = false, nudgeOnCooldown = false,
onsend, onsend,
@@ -16,11 +17,16 @@
messages: ChatMessage[]; messages: ChatMessage[];
myId: string; myId: string;
busy: boolean; busy: boolean;
// Chat and nudge are mutually exclusive by turn: on the player's own turn the // The input area has three turn-driven states: on your own turn the message field +
// message field + send are shown (and nudging makes no sense — there is no one to // send show while you may still post (canSend); once the one allowed message is sent a
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge // short caption replaces them until the next turn; on the opponent's turn only the
// cooldown is active the nudge is disabled with an "awaiting reply" caption. // 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; 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 // 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). // are disabled (there is no one to message or hurry yet).
waiting?: boolean; waiting?: boolean;
@@ -53,14 +59,18 @@
{/each} {/each}
</div> </div>
<div class="input"> <div class="input">
{#if myTurn} {#if canSend}
<input <input
maxlength="60" maxlength="60"
placeholder={t('chat.placeholder')} placeholder={t('chat.placeholder')}
bind:value={text} 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> <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} {:else}
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. --> <!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span> <span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
+23 -4
View File
@@ -3,6 +3,7 @@
import Chat from './Chat.svelte'; import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, clearChatUnread } from '../lib/app.svelte'; import { app, handleError, clearChatUnread } from '../lib/app.svelte';
import { canSendChat, sentThisTurn } from '../lib/chatlimit';
import type { ChatMessage, StateView } from '../lib/model'; import type { ChatMessage, StateView } from '../lib/model';
// The Chat tab body, hosted by CommsHub (which supplies the nav bar + tab bar). The // The Chat tab body, hosted by CommsHub (which supplies the nav bar + tab bar). The
@@ -20,6 +21,11 @@
const isMyTurn = $derived( const isMyTurn = $derived(
!!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat, !!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. // While the auto-match game still has no opponent, chat and nudge are both disabled.
const waiting = $derived(!!view && view.game.status === 'open'); const waiting = $derived(!!view && view.game.status === 'open');
const nudgeCooldownSecs = 3600; const nudgeCooldownSecs = 3600;
@@ -46,22 +52,35 @@
/* best-effort */ /* 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 { try {
view = await gateway.gameState(id, false); view = await gateway.gameState(id, false);
} catch (e) { } catch (e) {
handleError(e); if (initial) handleError(e);
} }
}
onMount(async () => {
await loadState(true);
await refresh(); 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(() => { $effect(() => {
const e = app.lastEvent; const e = app.lastEvent;
if (!e) return; if (!e) return;
if ((e.kind === 'chat_message' && e.message.gameId === id) || (e.kind === 'nudge' && e.gameId === id)) { if ((e.kind === 'chat_message' && e.message.gameId === id) || (e.kind === 'nudge' && e.gameId === id)) {
void refresh(); 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. // Re-evaluate the nudge cooldown on a timer so the control re-enables on time.
$effect(() => { $effect(() => {
@@ -91,4 +110,4 @@
} }
</script> </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.awaitingReply': "Waiting for the opponent's reply",
'chat.empty': 'No messages yet.', 'chat.empty': 'No messages yet.',
'chat.nudged': '{name} nudged you', 'chat.nudged': '{name} nudged you',
'chat.sentThisTurn': 'You can write again next turn.',
'profile.title': 'Profile', 'profile.title': 'Profile',
'profile.language': 'Language', 'profile.language': 'Language',
@@ -177,6 +178,7 @@ export const en = {
'error.chat_rejected': 'Message rejected (too long or contains contact info).', 'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.", '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_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.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.', 'error.not_a_player': 'You are not a player in this game.',
'error.already_queued': 'You are already in the queue.', '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.awaitingReply': 'Ждём реакцию соперника',
'chat.empty': 'Сообщений пока нет.', 'chat.empty': 'Сообщений пока нет.',
'chat.nudged': '{name} торопит вас', 'chat.nudged': '{name} торопит вас',
'chat.sentThisTurn': 'Можно написать снова в следующем ходу.',
'profile.title': 'Профиль', 'profile.title': 'Профиль',
'profile.language': 'Язык', 'profile.language': 'Язык',
@@ -178,6 +179,7 @@ export const ru: Record<MessageKey, string> = {
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.', 'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.', 'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
'error.game_finished': 'Эта игра уже завершена.', 'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.', 'error.not_a_player': 'Вы не участник этой игры.',
'error.already_queued': 'Вы уже в очереди.', 'error.already_queued': 'Вы уже в очереди.',