From c0e3fdb0460f7747b39a2526f0a46ce090f9043f Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 16 Jun 2026 22:12:48 +0330 Subject: [PATCH] feat(mm): wait longer for a real opponent; add "start with bots now" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server: a lone player in the online-league queue now keeps waiting (re-checking every 15s) up to 75s so an online opponent has a real chance to join; the moment a 2nd human queues they're matched together, and a full 4 still forms instantly. Add PlayNow hub method to force-start with bots on demand. - client: matchmaking screen shows a "شروع با ربات / Start with bots" button after a few seconds so the player can skip the wait; waiting copy updated; raise the "connection stuck" hint threshold to 90s so it no longer fires during normal waits. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/Hokm.Server/Game/GameManager.cs | 46 ++++++++++++++++---- server/src/Hokm.Server/Hubs/GameHub.cs | 1 + src/components/screens/MatchmakingScreen.tsx | 25 ++++++++++- src/lib/i18n.tsx | 6 ++- src/lib/online/mock-service.ts | 29 ++++++++++++ src/lib/online/service.ts | 2 + src/lib/online/signalr-service.ts | 4 ++ 7 files changed, 100 insertions(+), 13 deletions(-) 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);