Skip to content

Commit a256317

Browse files
committed
[PR-7] add emote feature
1 parent 3722060 commit a256317

12 files changed

Lines changed: 1326 additions & 5 deletions

File tree

backend/app/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ class Settings(BaseSettings):
5656
DEFAULT_MMR: int = 1000
5757
MMR_K_FACTOR: int = 32
5858

59+
# Emote system (PR-07).
60+
# EMOTE_COOLDOWN_SECONDS: minimum gap between any two emotes from one
61+
# connection. Attempts within this window are rejected with RATE_LIMITED.
62+
# EMOTE_BURST_CAP: maximum emotes allowed within EMOTE_BURST_WINDOW_SECONDS.
63+
# EMOTE_BURST_WINDOW_SECONDS: sliding window size for burst-cap enforcement.
64+
# EMOTE_SAME_REPEAT_CAP: max consecutive sends of the identical emote_id
65+
# before a soft block kicks in (same-emote spam prevention).
66+
EMOTE_COOLDOWN_SECONDS: float = 1.5
67+
EMOTE_BURST_CAP: int = 3
68+
EMOTE_BURST_WINDOW_SECONDS: int = 10
69+
EMOTE_SAME_REPEAT_CAP: int = 2
70+
5971
model_config = SettingsConfigDict(
6072
env_file=".env",
6173
env_file_encoding="utf-8",
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""
2+
Emote broadcast service (PR-07).
3+
4+
Responsibilities
5+
----------------
6+
* Validate that an emote_id belongs to the fixed permitted catalog.
7+
* Enforce per-connection rate limits (all values from settings):
8+
- Cooldown: EMOTE_COOLDOWN_SECONDS (1.5 s) between any two emotes.
9+
- Burst cap: at most EMOTE_BURST_CAP (3) emotes within
10+
EMOTE_BURST_WINDOW_SECONDS (10 s) sliding window.
11+
- Same-emote soft block: more than EMOTE_SAME_REPEAT_CAP (2) consecutive
12+
sends of the identical slug are denied until a different emote is sent.
13+
* Provide reset() for test isolation.
14+
15+
No broadcast or session logic lives here — the WS handler is responsible
16+
for fanout and mute filtering.
17+
18+
Emote catalog
19+
-------------
20+
Emotes are identified by a short ASCII slug. The client maps these to
21+
emoji / images. The server only validates the slug — it never stores or
22+
renders it.
23+
24+
Permitted slugs:
25+
thumbsup 👍 좋아요
26+
thumbsdown 👎 별로
27+
smile 😄 웃음
28+
sweat 😅 땀
29+
thinking 🤔 생각
30+
fire 🔥 열정
31+
cry 😭 눈물
32+
clap 👏 박수
33+
"""
34+
35+
from __future__ import annotations
36+
37+
import time
38+
39+
from app.config import settings
40+
41+
# ---------------------------------------------------------------------------
42+
# Catalog
43+
# ---------------------------------------------------------------------------
44+
45+
VALID_EMOTE_IDS: frozenset[str] = frozenset(
46+
{
47+
"thumbsup",
48+
"thumbsdown",
49+
"smile",
50+
"sweat",
51+
"thinking",
52+
"fire",
53+
"cry",
54+
"clap",
55+
}
56+
)
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# Service
61+
# ---------------------------------------------------------------------------
62+
63+
64+
class EmoteService:
65+
"""
66+
Stateful rate-limit tracker for emote broadcasts.
67+
68+
One instance is shared for the entire server process. All state is
69+
keyed by connection_id so there is no per-user persistence.
70+
State is cleared between tests via reset().
71+
"""
72+
73+
def __init__(self) -> None:
74+
# connection_id -> timestamp of the most recent allowed emote
75+
self._last_sent: dict[str, float] = {}
76+
# connection_id -> list of timestamps within the current burst window
77+
self._window_history: dict[str, list[float]] = {}
78+
# connection_id -> (last_emote_id, consecutive_count)
79+
# Tracks same-emote repetitions for soft-block enforcement.
80+
self._same_emote: dict[str, tuple[str, int]] = {}
81+
# connection_id -> reason string for the most recent denial
82+
# One of: "cooldown", "burst", "same_emote"
83+
self._last_deny_reason: dict[str, str] = {}
84+
85+
# ------------------------------------------------------------------
86+
# Validation
87+
# ------------------------------------------------------------------
88+
89+
def is_valid_emote(self, emote_id: str) -> bool:
90+
"""Return True if emote_id is in the permitted catalog."""
91+
return emote_id in VALID_EMOTE_IDS
92+
93+
# ------------------------------------------------------------------
94+
# Rate limiting
95+
# ------------------------------------------------------------------
96+
97+
def check_and_record(self, conn_id: str, emote_id: str) -> bool:
98+
"""
99+
Check whether conn_id may send *emote_id* right now.
100+
101+
Returns True and records the emission if allowed.
102+
Returns False (without recording) if any limit would be violated.
103+
104+
Rules applied in order:
105+
1. Cooldown: now − last_sent < EMOTE_COOLDOWN_SECONDS → deny.
106+
2. Burst cap: entries_in_window >= EMOTE_BURST_CAP → deny.
107+
3. Same-emote soft block: consecutive_same > EMOTE_SAME_REPEAT_CAP
108+
→ deny until a different emote is used.
109+
"""
110+
now = time.time()
111+
112+
# 1. Cooldown check
113+
last = self._last_sent.get(conn_id, 0.0)
114+
if now - last < settings.EMOTE_COOLDOWN_SECONDS:
115+
self._last_deny_reason[conn_id] = "cooldown"
116+
return False
117+
118+
# 2. Burst window — prune expired entries then count
119+
window = self._window_history.get(conn_id, [])
120+
window = [t for t in window if now - t < settings.EMOTE_BURST_WINDOW_SECONDS]
121+
if len(window) >= settings.EMOTE_BURST_CAP:
122+
self._last_deny_reason[conn_id] = "burst"
123+
return False
124+
125+
# 3. Same-emote soft block
126+
prev_id, streak = self._same_emote.get(conn_id, ("", 0))
127+
if emote_id == prev_id and streak >= settings.EMOTE_SAME_REPEAT_CAP:
128+
self._last_deny_reason[conn_id] = "same_emote"
129+
return False
130+
131+
# Allowed — record
132+
window.append(now)
133+
self._last_sent[conn_id] = now
134+
self._window_history[conn_id] = window
135+
136+
# Update same-emote streak counter
137+
if emote_id == prev_id:
138+
self._same_emote[conn_id] = (emote_id, streak + 1)
139+
else:
140+
self._same_emote[conn_id] = (emote_id, 1)
141+
142+
return True
143+
144+
def last_deny_reason(self, conn_id: str) -> str:
145+
"""
146+
Return the reason for the most recent rate-limit denial for conn_id.
147+
148+
Returns one of: ``"cooldown"``, ``"burst"``, ``"same_emote"``.
149+
Returns ``""`` if conn_id has never been denied.
150+
"""
151+
return self._last_deny_reason.get(conn_id, "")
152+
153+
def remaining_cooldown(self, conn_id: str) -> float:
154+
"""
155+
Return seconds until the cooldown expires for conn_id.
156+
157+
Returns 0.0 if the connection is not rate-limited.
158+
Useful for generating informative error messages.
159+
"""
160+
last = self._last_sent.get(conn_id, 0.0)
161+
remaining = settings.EMOTE_COOLDOWN_SECONDS - (time.time() - last)
162+
return max(0.0, remaining)
163+
164+
# ------------------------------------------------------------------
165+
# Lifecycle
166+
# ------------------------------------------------------------------
167+
168+
def reset(self) -> None:
169+
"""Clear all rate-limit state. Called between tests."""
170+
self._last_sent.clear()
171+
self._window_history.clear()
172+
self._same_emote.clear()
173+
self._last_deny_reason.clear()
174+
175+
176+
# ---------------------------------------------------------------------------
177+
# Module-level singleton
178+
# ---------------------------------------------------------------------------
179+
180+
emote_service = EmoteService()

backend/app/ws/connection_manager.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ def join_room(self, conn_id: str, room_code: str) -> None:
4747
self._room_members[room_code] = set()
4848
self._room_members[room_code].add(conn_id)
4949

50+
def get_room_members(self, room_code: str) -> list[str]:
51+
"""Return a snapshot of connection IDs currently in room_code."""
52+
return list(self._room_members.get(room_code, set()))
53+
5054
def leave_room(self, conn_id: str, room_code: str) -> None:
5155
self._room_members.get(room_code, set()).discard(conn_id)
5256

backend/app/ws/handler.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ async def handle_message(
9191
)
9292
elif msg_type == "QUICK_MATCH_LEAVE":
9393
await _quick_match_leave(ws, session, matchmaking_service)
94+
elif msg_type == "EMOTE_SEND":
95+
await _emote_send(ws, session, data, manager)
96+
elif msg_type == "EMOTE_MUTE":
97+
await _emote_mute(ws, session, data)
9498
elif msg_type == "PING":
9599
await ws.send_json({"type": "PONG", "data": {}})
96100
elif msg_type == "PONG":
@@ -1002,3 +1006,96 @@ async def _on_ready(rc: str, m) -> None:
10021006
room_code=room.room_code,
10031007
on_turn_ready=_on_ready,
10041008
)
1009+
1010+
1011+
# ---------------------------------------------------------------------------
1012+
# Emote handlers (PR-07)
1013+
# ---------------------------------------------------------------------------
1014+
1015+
1016+
async def _emote_send(
1017+
ws: WebSocket,
1018+
session: WsSession,
1019+
data: dict,
1020+
manager: ConnectionManager,
1021+
) -> None:
1022+
"""
1023+
Relay an emote from one player to all room participants.
1024+
1025+
Validation:
1026+
- Session must be authenticated and in a room.
1027+
- emote_id must be in the permitted catalog.
1028+
- Per-connection rate limits (cooldown + burst cap) must not be exceeded.
1029+
1030+
On success: broadcasts EMOTE_BROADCAST to every room member whose
1031+
session does not have emotes_muted=True.
1032+
The sender always receives the broadcast (they see their own emote).
1033+
"""
1034+
from app.services.emote_service import emote_service
1035+
1036+
if not session.is_authenticated:
1037+
await _error(ws, "NOT_AUTHENTICATED", "Authenticate before sending emotes")
1038+
return
1039+
1040+
if not session.in_room:
1041+
await _error(ws, "NOT_IN_ROOM", "Join a room before sending emotes")
1042+
return
1043+
1044+
emote_id = data.get("emote_id", "")
1045+
if not emote_service.is_valid_emote(emote_id):
1046+
await _error(ws, "INVALID_EMOTE", f"Unknown emote: {emote_id!r}")
1047+
return
1048+
1049+
if not emote_service.check_and_record(session.connection_id, emote_id):
1050+
reason = emote_service.last_deny_reason(session.connection_id)
1051+
if reason == "cooldown":
1052+
cooldown = emote_service.remaining_cooldown(session.connection_id)
1053+
msg = f"Sending emotes too fast — wait {cooldown:.1f}s"
1054+
elif reason == "burst":
1055+
msg = "Emote burst cap reached — slow down"
1056+
else: # same_emote
1057+
msg = "Send a different emote before repeating"
1058+
await ws.send_json(
1059+
{
1060+
"type": "ERROR",
1061+
"data": {
1062+
"code": "EMOTE_RATE_LIMITED",
1063+
"reason": reason,
1064+
"message": msg,
1065+
},
1066+
}
1067+
)
1068+
return
1069+
1070+
payload = {
1071+
"type": "EMOTE_BROADCAST",
1072+
"data": {
1073+
"seat_index": session.seat_index,
1074+
"emote_id": emote_id,
1075+
},
1076+
}
1077+
1078+
# Fan out to every room member, skipping connections with mutes.
1079+
for conn_id in manager.get_room_members(session.room_code):
1080+
target_session = manager.get_session(conn_id)
1081+
if target_session is not None and target_session.emotes_muted:
1082+
continue
1083+
await manager.send_to(conn_id, payload)
1084+
1085+
1086+
async def _emote_mute(
1087+
ws: WebSocket,
1088+
session: WsSession,
1089+
data: dict,
1090+
) -> None:
1091+
"""
1092+
Toggle emote broadcast reception for this connection.
1093+
1094+
The client sends {"type": "EMOTE_MUTE", "data": {"muted": true|false}}.
1095+
The server acknowledges with EMOTE_MUTE_ACK carrying the new state.
1096+
When muted=True, this connection will no longer receive EMOTE_BROADCAST
1097+
messages until it explicitly unmutes.
1098+
"""
1099+
muted = bool(data.get("muted", True))
1100+
session.emotes_muted = muted
1101+
await ws.send_json({"type": "EMOTE_MUTE_ACK", "data": {"muted": session.emotes_muted}})

backend/app/ws/session.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ class WsSession:
2828
# Cleared when a match is formed or the player leaves the queue manually.
2929
in_queue: bool = False
3030

31+
# True when this connection has opted out of receiving emote broadcasts
32+
# (PR-07). Emotes sent by others are silently dropped for this session.
33+
emotes_muted: bool = False
34+
3135
@property
3236
def is_authenticated(self) -> bool:
3337
return self.display_name is not None

backend/tests/conftest.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ def reset_matchmaking_service():
7272
matchmaking_service.reset()
7373

7474

75+
@pytest.fixture(autouse=True)
76+
def reset_emote_service():
77+
"""Clear per-connection rate-limit state between tests."""
78+
from app.services.emote_service import emote_service
79+
80+
emote_service.reset()
81+
yield
82+
emote_service.reset()
83+
84+
7585
@pytest.fixture()
7686
def db_engine():
7787
"""Fresh in-memory SQLite engine for one test."""

0 commit comments

Comments
 (0)