feat: shop buy-coins CTA, pin chats (max 3), surrender cooldown, OTP hardening
- Shop: when short on coins the detail sheet now shows "{n} more coins" + a
"Get coins" CTA that opens the buy-coins page (was a dead disabled button).
- Chat: pin/unpin conversations (max 3, persisted to localStorage); pinned float
to the top with a gold pin. i18n chat.pin/unpin/pinLimit.
- Surrender: server now rate-limits forfeit asks at a human teammate
(45s per-user cooldown) so it can't be spammed. (Bot teammate still ends
immediately; teammate confirm dialog already existed.)
- OTP login hardening: Kavenegar send now parses the API status from the body
(HTTP 200 can still be a failure) + logs it + 12s timeout; client auth fetch
gets a 20s AbortController timeout so a lost response surfaces an error
instead of freezing on "sending…".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Hokm.Server.Auth;
|
||||
|
||||
@@ -147,10 +148,36 @@ public sealed class OtpService
|
||||
$"?receptor={Uri.EscapeDataString(phone)}" +
|
||||
$"&token={Uri.EscapeDataString(code)}" +
|
||||
$"&template={Uri.EscapeDataString(_opts.Template)}";
|
||||
var resp = await Http.GetAsync(url);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"Kavenegar {(int)resp.StatusCode}: {body}");
|
||||
|
||||
// Bound the call so a hung/slow Kavenegar can't freeze the login request.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(12));
|
||||
var resp = await Http.GetAsync(url, cts.Token);
|
||||
var body = await resp.Content.ReadAsStringAsync(cts.Token);
|
||||
|
||||
// Kavenegar replies HTTP 200 with {"return":{"status":200,"message":...}}.
|
||||
// status 200 = queued/sent; anything else (411 receptor, 418 credit,
|
||||
// 422 template, 424 template-params…) means it did NOT send.
|
||||
int? apiStatus = null;
|
||||
string? apiMessage = null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("return", out var ret))
|
||||
{
|
||||
if (ret.TryGetProperty("status", out var st) && st.ValueKind == JsonValueKind.Number)
|
||||
apiStatus = st.GetInt32();
|
||||
if (ret.TryGetProperty("message", out var msg)) apiMessage = msg.GetString();
|
||||
}
|
||||
}
|
||||
catch { /* non-JSON body */ }
|
||||
|
||||
if (!resp.IsSuccessStatusCode || (apiStatus.HasValue && apiStatus != 200))
|
||||
{
|
||||
_log.LogWarning("Kavenegar send FAILED http={Http} apiStatus={Api} message={Msg} body={Body}",
|
||||
(int)resp.StatusCode, apiStatus, apiMessage, body);
|
||||
throw new InvalidOperationException($"Kavenegar http={(int)resp.StatusCode} status={apiStatus} msg={apiMessage}");
|
||||
}
|
||||
_log.LogInformation("Kavenegar OTP sent to {Phone} (status {Status})", phone, apiStatus ?? 200);
|
||||
}
|
||||
|
||||
/// <summary>Normalize to Kavenegar's 09xxxxxxxxx form (handles +98 / 98 prefixes).</summary>
|
||||
|
||||
@@ -53,6 +53,9 @@ public sealed class GameRoom : IDisposable
|
||||
private int? _forfeitPendingTeam;
|
||||
private string? _forfeitRequester;
|
||||
private Timer? _forfeitTimer;
|
||||
// Per-user cooldown so a player can't spam surrender requests at their teammate.
|
||||
private const int ForfeitCooldownSeconds = 45;
|
||||
private readonly Dictionary<string, DateTime> _forfeitNextAllowed = new();
|
||||
|
||||
public string Id { get; } = Guid.NewGuid().ToString("N")[..8];
|
||||
public string Code { get; } = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
|
||||
@@ -198,12 +201,16 @@ public sealed class GameRoom : IDisposable
|
||||
if (seat is null) return;
|
||||
int team = seat.Value % 2;
|
||||
var mate = Seats.FirstOrDefault(s => s.Seat % 2 == team && s.Seat != seat.Value);
|
||||
// No human teammate to ask → forfeit immediately.
|
||||
// No human teammate to ask → forfeit immediately (no cooldown needed).
|
||||
if (mate is null || mate.IsBot || mate.UserId is null || !mate.Connected)
|
||||
{
|
||||
FinalizeForfeit(team);
|
||||
return;
|
||||
}
|
||||
// Rate-limit repeated asks at a human teammate (anti-nag).
|
||||
if (_forfeitNextAllowed.TryGetValue(userId, out var until) && DateTime.UtcNow < until)
|
||||
return;
|
||||
_forfeitNextAllowed[userId] = DateTime.UtcNow.AddSeconds(ForfeitCooldownSeconds);
|
||||
_forfeitPendingTeam = team;
|
||||
_forfeitRequester = userId;
|
||||
var requester = Seats.First(s => s.UserId == userId);
|
||||
|
||||
Reference in New Issue
Block a user