feat: shop buy-coins CTA, pin chats (max 3), surrender cooldown, OTP hardening
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 29s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m6s

- 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:
soroush.asadi
2026-06-15 11:01:14 +03:30
parent 97d3a02a3c
commit 974a6bf0ae
6 changed files with 189 additions and 63 deletions
+31 -4
View File
@@ -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>
+8 -1
View File
@@ -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);