feat: OTP rate limit, private-room invite UX, in-game UI fixes
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 54s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m12s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m11s

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:
soroush.asadi
2026-06-14 00:30:20 +03:30
parent 78878efc22
commit bc695bc8e9
12 changed files with 257 additions and 58 deletions
+19 -8
View File
@@ -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;
}