feat: OTP rate limit, private-room invite UX, in-game UI fixes
Auth / security
- Rate-limit real SMS OTP sends (dev mode unlimited): 60s resend cooldown,
5 per phone/hour, 300/hour global backstop. OtpService.CheckAndRecordRate;
POST /api/auth/otp/request returns 429 {error,retryAfter}; AuthScreen shows
auth.rateLimited. Knobs in appsettings Sms (Sms__* env).
Private rooms (invite)
- Cancel-invite button on pending seats; friend picker shows presence
(online/offline/in-game, sorted online-first) and flags in-game players.
- Mock invite stays pending ~3.5s and a cancel truly stops the auto-accept
(was a bug that re-seated cancelled invites).
In-game UI
- Scoreboard is compact + shrink-safe (no overflow on narrow screens).
- Played trick cards land dead-center (were ~2px off the corner anchor).
Plus the in-flight typing-indicator work (GameHub, ChatScreen).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+10
-2
@@ -152,6 +152,7 @@ const fa: Dict = {
|
||||
"chat.placeholder": "پیام بنویسید…",
|
||||
"chat.send": "ارسال",
|
||||
"chat.emoji": "ایموجی",
|
||||
"chat.typing": "در حال نوشتن…",
|
||||
"city.rewardTitle": "شهرت را انتخاب کن!",
|
||||
"city.rewardSub": "{n} سکه هدیه بگیر",
|
||||
"city.search": "جستجوی شهر…",
|
||||
@@ -234,11 +235,13 @@ const fa: Dict = {
|
||||
"room.invite": "دعوت دوست",
|
||||
"room.addBot": "ربات",
|
||||
"room.empty": "خالی",
|
||||
"room.waiting": "در انتظار…",
|
||||
"room.waiting": "در انتظار پذیرش…",
|
||||
"room.cancelInvite": "لغو دعوت",
|
||||
"room.start": "شروع بازی",
|
||||
"room.stake": "سکه ورودی",
|
||||
"room.leave": "ترک اتاق",
|
||||
"room.pickFriend": "یک دوست را انتخاب کنید",
|
||||
"room.inviteHint": "صندلی تا پذیرش دعوت در حالت انتظار میماند",
|
||||
|
||||
"mm.title": "جستجوی بازیکن",
|
||||
"mm.searching": "در حال یافتن حریف…",
|
||||
@@ -285,6 +288,7 @@ const fa: Dict = {
|
||||
"auth.invalidPhone": "شماره موبایل را درست وارد کنید",
|
||||
"auth.sending": "در حال ارسال…",
|
||||
"auth.sendFailed": "ارسال پیامک ناموفق بود، دوباره تلاش کنید",
|
||||
"auth.rateLimited": "درخواستهای زیاد. لطفاً {s} ثانیه دیگر تلاش کنید",
|
||||
"auth.codeSent": "کد به شماره شما پیامک شد",
|
||||
"auth.changeNumber": "تغییر شماره",
|
||||
"auth.otherSoon": "سایر روشهای ورود بهزودی فعال میشوند",
|
||||
@@ -524,6 +528,7 @@ const en: Dict = {
|
||||
"chat.placeholder": "Type a message…",
|
||||
"chat.send": "Send",
|
||||
"chat.emoji": "Emoji",
|
||||
"chat.typing": "typing…",
|
||||
"city.rewardTitle": "Set your city!",
|
||||
"city.rewardSub": "Earn {n} coins",
|
||||
"city.search": "Search city…",
|
||||
@@ -606,11 +611,13 @@ const en: Dict = {
|
||||
"room.invite": "Invite friend",
|
||||
"room.addBot": "Bot",
|
||||
"room.empty": "Empty",
|
||||
"room.waiting": "Waiting…",
|
||||
"room.waiting": "Waiting to accept…",
|
||||
"room.cancelInvite": "Cancel invite",
|
||||
"room.start": "Start game",
|
||||
"room.stake": "Entry coins",
|
||||
"room.leave": "Leave room",
|
||||
"room.pickFriend": "Pick a friend",
|
||||
"room.inviteHint": "The seat stays pending until they accept the invite",
|
||||
|
||||
"mm.title": "Finding players",
|
||||
"mm.searching": "Searching for opponents…",
|
||||
@@ -654,6 +661,7 @@ const en: Dict = {
|
||||
"auth.invalidPhone": "Enter a valid mobile number",
|
||||
"auth.sending": "Sending…",
|
||||
"auth.sendFailed": "Couldn't send the SMS, try again",
|
||||
"auth.rateLimited": "Too many requests. Please try again in {s}s",
|
||||
"auth.codeSent": "Code sent to your number",
|
||||
"auth.changeNumber": "Change number",
|
||||
"auth.otherSoon": "Other sign-in methods coming soon",
|
||||
|
||||
@@ -678,6 +678,12 @@ export class MockOnlineService implements OnlineService {
|
||||
return () => this.chatCbs.delete(cb);
|
||||
}
|
||||
|
||||
// Offline mock has no real peers, so typing pings are no-ops.
|
||||
sendTyping(): void {}
|
||||
onTyping(): Unsubscribe {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/* ---------------------------- reactions ---------------------------- */
|
||||
|
||||
async sendReaction(reaction: string) {
|
||||
@@ -780,6 +786,17 @@ export class MockOnlineService implements OnlineService {
|
||||
};
|
||||
}
|
||||
|
||||
/** The invited player "accepts" after a realistic delay — but only if the host
|
||||
* hasn't cancelled the invite (seat still shows them as invited) in the meantime. */
|
||||
private acceptInviteWhenPending(seat: 1 | 2 | 3, friendId: string) {
|
||||
this.after(3500, () => {
|
||||
const cur = this.room?.seats.find((x) => x.seat === seat);
|
||||
if (cur?.kind !== "invited" || cur.player?.id !== friendId) return; // cancelled → don't seat them
|
||||
this.setSeat(seat, this.friendSeat(seat, friendId, false));
|
||||
this.emitRoom();
|
||||
});
|
||||
}
|
||||
|
||||
async setPartner(roomId: string, friendId: string | null) {
|
||||
void roomId;
|
||||
if (!this.room) throw new Error("NO_ROOM");
|
||||
@@ -787,10 +804,7 @@ export class MockOnlineService implements OnlineService {
|
||||
this.setSeat(2, { seat: 2, kind: "empty" });
|
||||
} else {
|
||||
this.setSeat(2, this.friendSeat(2, friendId, true));
|
||||
this.after(1100, () => {
|
||||
this.setSeat(2, this.friendSeat(2, friendId, false));
|
||||
this.emitRoom();
|
||||
});
|
||||
this.acceptInviteWhenPending(2, friendId);
|
||||
}
|
||||
this.emitRoom();
|
||||
return this.room;
|
||||
@@ -800,10 +814,7 @@ export class MockOnlineService implements OnlineService {
|
||||
void roomId;
|
||||
if (!this.room) throw new Error("NO_ROOM");
|
||||
this.setSeat(seat, this.friendSeat(seat, friendId, true));
|
||||
this.after(1100, () => {
|
||||
this.setSeat(seat, this.friendSeat(seat, friendId, false));
|
||||
this.emitRoom();
|
||||
});
|
||||
this.acceptInviteWhenPending(seat, friendId);
|
||||
this.emitRoom();
|
||||
return this.room;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ export interface OnlineService {
|
||||
sendMessage(friendId: string, text: string): Promise<ChatMessage>;
|
||||
markRead(friendId: string): Promise<void>;
|
||||
onChat(cb: (friendId: string, messages: ChatMessage[]) => void): Unsubscribe;
|
||||
/** Tell a peer we're typing (ephemeral); subscribe to peers' typing pings. */
|
||||
sendTyping(friendId: string): void;
|
||||
onTyping(cb: (fromId: string) => void): Unsubscribe;
|
||||
|
||||
/* ----- reactions (in-game emotes) ----- */
|
||||
sendReaction(reaction: string): Promise<void>;
|
||||
|
||||
@@ -59,6 +59,7 @@ export class SignalrService implements OnlineService {
|
||||
private rewardCbs = new Set<(r: RewardResult) => void>();
|
||||
private friendCbs = new Set<(f: Friend[]) => void>();
|
||||
private chatCbs = new Set<(id: string, m: ChatMessage[]) => void>();
|
||||
private typingCbs = new Set<(fromId: string) => void>();
|
||||
private forfeitCbs = new Set<(r: ForfeitRequest | null) => void>();
|
||||
private cachedProfile: UserProfile | null = null;
|
||||
|
||||
@@ -120,6 +121,8 @@ export class SignalrService implements OnlineService {
|
||||
conn.on("state", (s: ServerGameState) => this.stateCbs.forEach((cb) => cb(s)));
|
||||
conn.on("reaction", (r: { seat: number; reaction: string }) =>
|
||||
this.reactionCbs.forEach((cb) => cb(r.seat, r.reaction)));
|
||||
conn.on("typing", (m: { from: string }) =>
|
||||
this.typingCbs.forEach((cb) => cb(m.from)));
|
||||
conn.on("notification", (n: AppNotification) =>
|
||||
this.notifCbs.forEach((cb) => cb(n)));
|
||||
conn.on("profile", (p: UserProfile) =>
|
||||
@@ -404,6 +407,8 @@ export class SignalrService implements OnlineService {
|
||||
}
|
||||
async markRead() { /* server marks read when messages are fetched */ }
|
||||
onChat(cb: (id: string, m: ChatMessage[]) => void) { this.chatCbs.add(cb); return () => this.chatCbs.delete(cb); }
|
||||
sendTyping(id: string) { this.conn?.invoke("Typing", id).catch(() => {}); }
|
||||
onTyping(cb: (fromId: string) => void) { this.typingCbs.add(cb); return () => this.typingCbs.delete(cb); }
|
||||
|
||||
onNotification(cb: (n: AppNotification) => void): Unsubscribe {
|
||||
// Real notifications only — server hub "notification" events + app-generated
|
||||
|
||||
Reference in New Issue
Block a user