diff --git a/server/src/Hokm.Server/Game/GameManager.cs b/server/src/Hokm.Server/Game/GameManager.cs
index 135bc64..c779c7e 100644
--- a/server/src/Hokm.Server/Game/GameManager.cs
+++ b/server/src/Hokm.Server/Game/GameManager.cs
@@ -17,11 +17,14 @@ public sealed class Player
/// In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)
public sealed partial class GameManager
{
- // Real players get priority: wait exactly 15s for humans to join; whoever
- // hasn't joined the table by then is replaced with a bot when the match forms.
- // (Mirror of MATCH_QUEUE_WAIT_MS on the client — keep both in sync.)
+ // Real players get priority. We re-check the queue every QueueWaitMs; the
+ // moment a second human is waiting they're matched together (+ bots for any
+ // empty seats), and a full group of 4 forms instantly. A player left ALONE
+ // keeps waiting up to MaxAloneWaitMs so an online opponent has a genuine
+ // chance to join before we fall back to a bot table. (QueueWaitMs mirrors
+ // MATCH_QUEUE_WAIT_MS on the client — keep both in sync.)
private const int QueueWaitMs = 15000;
- private int NextQueueWaitMs() => QueueWaitMs;
+ private const int MaxAloneWaitMs = 75000;
private static readonly string[] BotNames =
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
@@ -33,7 +36,7 @@ public sealed partial class GameManager
private readonly ConcurrentDictionary _rooms = new();
private readonly ConcurrentDictionary _userRoom = new(); // userId -> roomId
private readonly object _mmLock = new();
- private readonly List<(Player player, Timer timer)> _waiting = new();
+ private readonly List<(Player player, Timer timer, DateTime since)> _waiting = new();
private readonly Random _rng = new();
public GameManager(IHubContext hub, IServiceScopeFactory scopes)
@@ -60,8 +63,8 @@ public sealed partial class GameManager
lock (_mmLock)
{
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
- var timer = new Timer(_ => FlushTicket(p.UserId), null, NextQueueWaitMs(), Timeout.Infinite);
- _waiting.Add((p, timer));
+ var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
+ _waiting.Add((p, timer, DateTime.UtcNow));
_ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
new MatchmakingStateDto("searching", _waiting.Count, null));
if (_waiting.Count >= 4) FormGroupLocked(4);
@@ -77,12 +80,37 @@ public sealed partial class GameManager
}
}
- private void FlushTicket(string userId)
+ /// Player asked to start right now — match any humans waiting and
+ /// fill the rest with bots, instead of waiting out the queue.
+ public void PlayNow(string userId)
{
lock (_mmLock)
{
if (!_waiting.Any(w => w.player.UserId == userId)) return;
- FormGroupLocked(_waiting.Count); // start with whoever is waiting; bots fill the rest
+ FormGroupLocked(_waiting.Count);
+ }
+ }
+
+ private void FlushTicket(string userId)
+ {
+ lock (_mmLock)
+ {
+ var idx = _waiting.FindIndex(w => w.player.UserId == userId);
+ if (idx < 0) return;
+
+ // A second human is already waiting → seat them together now and let
+ // bots fill any empty chairs (real players matched immediately).
+ if (_waiting.Count >= 2) { FormGroupLocked(_waiting.Count); return; }
+
+ // Alone: keep the table open for an online opponent until the max wait
+ // elapses, then fall back to a bot table. Re-arm the re-check timer.
+ var waited = (DateTime.UtcNow - _waiting[idx].since).TotalMilliseconds;
+ if (waited < MaxAloneWaitMs)
+ {
+ _waiting[idx].timer.Change(QueueWaitMs, Timeout.Infinite);
+ return;
+ }
+ FormGroupLocked(1);
}
}
diff --git a/server/src/Hokm.Server/Hubs/GameHub.cs b/server/src/Hokm.Server/Hubs/GameHub.cs
index 8543418..1ca81ae 100644
--- a/server/src/Hokm.Server/Hubs/GameHub.cs
+++ b/server/src/Hokm.Server/Hubs/GameHub.cs
@@ -37,6 +37,7 @@ public sealed class GameHub : Hub
});
public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid);
+ public void PlayNow() => _manager.PlayNow(Uid);
public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId);
public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit);
public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction);
diff --git a/src/components/screens/MatchmakingScreen.tsx b/src/components/screens/MatchmakingScreen.tsx
index 31be958..44db9cf 100644
--- a/src/components/screens/MatchmakingScreen.tsx
+++ b/src/components/screens/MatchmakingScreen.tsx
@@ -60,6 +60,15 @@ export function MatchmakingScreen() {
go("online");
};
+ // Stop waiting for an online opponent — start immediately with bots.
+ const startNow = async () => {
+ try {
+ await getService().playNow();
+ } catch {
+ /* ignore — server will still fall back to bots after the wait window */
+ }
+ };
+
const enter = () => {
const players = getService().getMatchPlayers();
if (!players) return;
@@ -131,7 +140,7 @@ export function MatchmakingScreen() {
{searching && (
<>
{elapsed}s
- {elapsed >= 28 ? (
+ {elapsed >= 90 ? (
{t("mm.stuck")}
) : (
{t("mm.fillHint")}
@@ -185,10 +194,22 @@ export function MatchmakingScreen() {
})}
-
+
+ {/* After a few seconds of waiting, let the player skip the wait and
+ start against bots instead of waiting for a real opponent. */}
+ {searching && elapsed >= 5 && (
+
+ {t("mm.playNow")}
+
+ )}
{ready && (
{
+ if (this.matchmaking.phase !== "found") return;
+ this.matchmaking.phase = "ready";
+ this.matchPlayers = players.map((p) => ({
+ id: p.id, displayName: p.displayName, avatar: p.avatar, level: p.level,
+ }));
+ const opps = players.slice(1);
+ this.currentOppRating = opps.reduce((s, p) => s + p.rating, 0) / Math.max(1, opps.length);
+ this.emitMM();
+ });
+ }
+
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
this.mmCbs.add(cb);
return () => this.mmCbs.delete(cb);
diff --git a/src/lib/online/service.ts b/src/lib/online/service.ts
index cdc87be..e3886d4 100644
--- a/src/lib/online/service.ts
+++ b/src/lib/online/service.ts
@@ -132,6 +132,8 @@ export interface OnlineService {
/* ----- matchmaking ----- */
startMatchmaking(opts: MatchmakingOptions): Promise;
cancelMatchmaking(): Promise;
+ /** Stop waiting for online players — start now, filling empty seats with bots. */
+ playNow(): Promise;
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
/* ----- match players (for the online game driver) ----- */
diff --git a/src/lib/online/signalr-service.ts b/src/lib/online/signalr-service.ts
index 2b06e55..7e29451 100644
--- a/src/lib/online/signalr-service.ts
+++ b/src/lib/online/signalr-service.ts
@@ -310,6 +310,10 @@ export class SignalrService implements OnlineService {
this.emitMM("idle");
}
+ async playNow() {
+ await this.conn?.invoke("PlayNow");
+ }
+
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
this.mmCbs.add(cb);
return () => this.mmCbs.delete(cb);