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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user