feat(mm): wait longer for a real opponent; add "start with bots now"
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -17,11 +17,14 @@ public sealed class Player
|
|||||||
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
/// <summary>In-memory matchmaking + room registry. (EF/Postgres persistence is a TODO.)</summary>
|
||||||
public sealed partial class GameManager
|
public sealed partial class GameManager
|
||||||
{
|
{
|
||||||
// Real players get priority: wait exactly 15s for humans to join; whoever
|
// Real players get priority. We re-check the queue every QueueWaitMs; the
|
||||||
// hasn't joined the table by then is replaced with a bot when the match forms.
|
// moment a second human is waiting they're matched together (+ bots for any
|
||||||
// (Mirror of MATCH_QUEUE_WAIT_MS on the client — keep both in sync.)
|
// 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 const int QueueWaitMs = 15000;
|
||||||
private int NextQueueWaitMs() => QueueWaitMs;
|
private const int MaxAloneWaitMs = 75000;
|
||||||
|
|
||||||
private static readonly string[] BotNames =
|
private static readonly string[] BotNames =
|
||||||
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
|
{ "آرش", "کیان", "نیلوفر", "سارا", "رضا", "مهسا", "امیر", "پارسا", "الناز", "بابک" };
|
||||||
@@ -33,7 +36,7 @@ public sealed partial class GameManager
|
|||||||
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
|
private readonly ConcurrentDictionary<string, GameRoom> _rooms = new();
|
||||||
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
|
private readonly ConcurrentDictionary<string, string> _userRoom = new(); // userId -> roomId
|
||||||
private readonly object _mmLock = new();
|
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();
|
private readonly Random _rng = new();
|
||||||
|
|
||||||
public GameManager(IHubContext<GameHub> hub, IServiceScopeFactory scopes)
|
public GameManager(IHubContext<GameHub> hub, IServiceScopeFactory scopes)
|
||||||
@@ -60,8 +63,8 @@ public sealed partial class GameManager
|
|||||||
lock (_mmLock)
|
lock (_mmLock)
|
||||||
{
|
{
|
||||||
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
|
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
|
||||||
var timer = new Timer(_ => FlushTicket(p.UserId), null, NextQueueWaitMs(), Timeout.Infinite);
|
var timer = new Timer(_ => FlushTicket(p.UserId), null, QueueWaitMs, Timeout.Infinite);
|
||||||
_waiting.Add((p, timer));
|
_waiting.Add((p, timer, DateTime.UtcNow));
|
||||||
_ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
|
_ = _hub.Clients.User(p.UserId).SendAsync("matchmaking",
|
||||||
new MatchmakingStateDto("searching", _waiting.Count, null));
|
new MatchmakingStateDto("searching", _waiting.Count, null));
|
||||||
if (_waiting.Count >= 4) FormGroupLocked(4);
|
if (_waiting.Count >= 4) FormGroupLocked(4);
|
||||||
@@ -77,12 +80,37 @@ public sealed partial class GameManager
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FlushTicket(string userId)
|
/// <summary>Player asked to start right now — match any humans waiting and
|
||||||
|
/// fill the rest with bots, instead of waiting out the queue.</summary>
|
||||||
|
public void PlayNow(string userId)
|
||||||
{
|
{
|
||||||
lock (_mmLock)
|
lock (_mmLock)
|
||||||
{
|
{
|
||||||
if (!_waiting.Any(w => w.player.UserId == userId)) return;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public sealed class GameHub : Hub
|
|||||||
});
|
});
|
||||||
|
|
||||||
public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid);
|
public void CancelMatchmaking() => _manager.CancelMatchmaking(Uid);
|
||||||
|
public void PlayNow() => _manager.PlayNow(Uid);
|
||||||
public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId);
|
public void PlayCard(string cardId) => _manager.PlayCard(Uid, cardId);
|
||||||
public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit);
|
public void ChooseTrump(string suit) => _manager.ChooseTrump(Uid, suit);
|
||||||
public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction);
|
public void SendReaction(string reaction) => _manager.SendReaction(Uid, reaction);
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export function MatchmakingScreen() {
|
|||||||
go("online");
|
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 enter = () => {
|
||||||
const players = getService().getMatchPlayers();
|
const players = getService().getMatchPlayers();
|
||||||
if (!players) return;
|
if (!players) return;
|
||||||
@@ -131,7 +140,7 @@ export function MatchmakingScreen() {
|
|||||||
{searching && (
|
{searching && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-2 text-3xl font-black gold-text tabular-nums">{elapsed}s</div>
|
<div className="mt-2 text-3xl font-black gold-text tabular-nums">{elapsed}s</div>
|
||||||
{elapsed >= 28 ? (
|
{elapsed >= 90 ? (
|
||||||
<p className="text-rose-300 text-xs mt-2 max-w-[18rem]">{t("mm.stuck")}</p>
|
<p className="text-rose-300 text-xs mt-2 max-w-[18rem]">{t("mm.stuck")}</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-cream/50 text-xs mt-1 max-w-[16rem]">{t("mm.fillHint")}</p>
|
<p className="text-cream/50 text-xs mt-1 max-w-[16rem]">{t("mm.fillHint")}</p>
|
||||||
@@ -185,10 +194,22 @@ export function MatchmakingScreen() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-10 flex gap-3">
|
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||||
<button onClick={cancel} className="press-3d glass rounded-2xl px-6 py-3 text-cream/70">
|
<button onClick={cancel} className="press-3d glass rounded-2xl px-6 py-3 text-cream/70">
|
||||||
{t("mm.cancel")}
|
{t("mm.cancel")}
|
||||||
</button>
|
</button>
|
||||||
|
{/* 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 && (
|
||||||
|
<motion.button
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
onClick={startNow}
|
||||||
|
className="press-3d btn-gold rounded-2xl px-6 py-3"
|
||||||
|
>
|
||||||
|
{t("mm.playNow")}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
{ready && (
|
{ready && (
|
||||||
<motion.button
|
<motion.button
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
|||||||
+4
-2
@@ -255,13 +255,14 @@ const fa: Dict = {
|
|||||||
"mm.searching": "در حال یافتن حریف…",
|
"mm.searching": "در حال یافتن حریف…",
|
||||||
"mm.found": "بازیکنان پیدا شدند!",
|
"mm.found": "بازیکنان پیدا شدند!",
|
||||||
"mm.ready": "آماده شروع",
|
"mm.ready": "آماده شروع",
|
||||||
"mm.fillHint": "اگر بازیکن آنلاینی پیدا نشود، رباتها جایگزین میشوند",
|
"mm.fillHint": "منتظر پیوستن یک بازیکن آنلاین هستیم… میتوانی همین حالا با ربات شروع کنی",
|
||||||
"mm.stuck": "اتصال به سرور طول کشید. لطفاً «لغو» را بزنید و دوباره تلاش کنید.",
|
"mm.stuck": "اتصال به سرور طول کشید. لطفاً «لغو» را بزنید و دوباره تلاش کنید.",
|
||||||
"intro.found": "بازیکنان آمادهاند!",
|
"intro.found": "بازیکنان آمادهاند!",
|
||||||
"intro.getReady": "بازی در حال شروع است…",
|
"intro.getReady": "بازی در حال شروع است…",
|
||||||
"intro.go": "شروع!",
|
"intro.go": "شروع!",
|
||||||
"mm.cancel": "لغو",
|
"mm.cancel": "لغو",
|
||||||
"mm.start": "ورود به بازی",
|
"mm.start": "ورود به بازی",
|
||||||
|
"mm.playNow": "شروع با ربات",
|
||||||
|
|
||||||
"lead.title": "جدول امتیازات",
|
"lead.title": "جدول امتیازات",
|
||||||
"lead.rank": "رتبه",
|
"lead.rank": "رتبه",
|
||||||
@@ -642,10 +643,11 @@ const en: Dict = {
|
|||||||
"mm.searching": "Searching for opponents…",
|
"mm.searching": "Searching for opponents…",
|
||||||
"mm.found": "Players found!",
|
"mm.found": "Players found!",
|
||||||
"mm.ready": "Ready to start",
|
"mm.ready": "Ready to start",
|
||||||
"mm.fillHint": "If no online players are found, bots will fill in",
|
"mm.fillHint": "Waiting for an online player to join… or start now with bots",
|
||||||
"mm.stuck": "Connecting to the server is taking too long. Tap Cancel and try again.",
|
"mm.stuck": "Connecting to the server is taking too long. Tap Cancel and try again.",
|
||||||
"mm.cancel": "Cancel",
|
"mm.cancel": "Cancel",
|
||||||
"mm.start": "Enter game",
|
"mm.start": "Enter game",
|
||||||
|
"mm.playNow": "Start with bots",
|
||||||
|
|
||||||
"lead.title": "Leaderboard",
|
"lead.title": "Leaderboard",
|
||||||
"lead.rank": "Rank",
|
"lead.rank": "Rank",
|
||||||
|
|||||||
@@ -984,6 +984,35 @@ export class MockOnlineService implements OnlineService {
|
|||||||
this.matchmaking.phase = "idle";
|
this.matchmaking.phase = "idle";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async playNow() {
|
||||||
|
if (this.matchmaking.phase !== "searching" && this.matchmaking.phase !== "queued") return;
|
||||||
|
const me = this.profile!;
|
||||||
|
// Keep whoever has already joined; fill the remaining seats with bots.
|
||||||
|
const players = this.matchmaking.players.slice();
|
||||||
|
while (players.length < 4) {
|
||||||
|
players.push({
|
||||||
|
id: rid("bot"),
|
||||||
|
displayName: pick(PERSIAN_NAMES),
|
||||||
|
avatar: pick(AVATARS).id,
|
||||||
|
level: randInt(1, 50),
|
||||||
|
rating: me.rating + randInt(-150, 150),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.matchmaking.players = players;
|
||||||
|
this.matchmaking.phase = "found";
|
||||||
|
this.emitMM();
|
||||||
|
this.after(700, () => {
|
||||||
|
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 {
|
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
|
||||||
this.mmCbs.add(cb);
|
this.mmCbs.add(cb);
|
||||||
return () => this.mmCbs.delete(cb);
|
return () => this.mmCbs.delete(cb);
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ export interface OnlineService {
|
|||||||
/* ----- matchmaking ----- */
|
/* ----- matchmaking ----- */
|
||||||
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
|
startMatchmaking(opts: MatchmakingOptions): Promise<void>;
|
||||||
cancelMatchmaking(): Promise<void>;
|
cancelMatchmaking(): Promise<void>;
|
||||||
|
/** Stop waiting for online players — start now, filling empty seats with bots. */
|
||||||
|
playNow(): Promise<void>;
|
||||||
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
|
onMatchmaking(cb: (state: MatchmakingState) => void): Unsubscribe;
|
||||||
|
|
||||||
/* ----- match players (for the online game driver) ----- */
|
/* ----- match players (for the online game driver) ----- */
|
||||||
|
|||||||
@@ -310,6 +310,10 @@ export class SignalrService implements OnlineService {
|
|||||||
this.emitMM("idle");
|
this.emitMM("idle");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async playNow() {
|
||||||
|
await this.conn?.invoke("PlayNow");
|
||||||
|
}
|
||||||
|
|
||||||
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
|
onMatchmaking(cb: (s: MatchmakingState) => void): Unsubscribe {
|
||||||
this.mmCbs.add(cb);
|
this.mmCbs.add(cb);
|
||||||
return () => this.mmCbs.delete(cb);
|
return () => this.mmCbs.delete(cb);
|
||||||
|
|||||||
Reference in New Issue
Block a user