feat(online): live queue count — friends see each other waiting
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 1m24s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m10s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m52s

The server only sent the queue size to the player who just joined, and the
client dropped the count entirely (emitMM ignored s.players). So two friends
queuing together never saw each other, even though the server does seat 2+
waiting humans together within ~25s.

- Server: BroadcastQueueLocked() pushes the current queue size to EVERY waiting
  player on join/cancel (not just the joiner).
- Client: thread the count through emitMM → MatchmakingState.waiting.
- MatchmakingScreen shows "N players in queue" (mm.inQueue) when ≥2 humans wait,
  so friends can tell they're queued together before bots fill the empty seats.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-19 19:26:13 +03:30
parent fe3bedc631
commit 940e2af6d2
5 changed files with 36 additions and 6 deletions
+18 -3
View File
@@ -67,9 +67,10 @@ public sealed partial class GameManager
if (_waiting.Any(w => w.player.UserId == p.UserId)) return;
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);
// Tell EVERYONE still waiting the new count (so friends queuing together
// see each other join), not just the player who just joined.
BroadcastQueueLocked();
}
}
@@ -78,9 +79,23 @@ public sealed partial class GameManager
lock (_mmLock)
{
var idx = _waiting.FindIndex(w => w.player.UserId == userId);
if (idx >= 0) { _waiting[idx].timer.Dispose(); _waiting.RemoveAt(idx); }
if (idx >= 0)
{
_waiting[idx].timer.Dispose();
_waiting.RemoveAt(idx);
BroadcastQueueLocked();
}
}
}
/// <summary>Push the current queue size to every waiting player (call inside _mmLock).</summary>
private void BroadcastQueueLocked()
{
int n = _waiting.Count;
foreach (var w in _waiting)
_ = _hub.Clients.User(w.player.UserId).SendAsync("matchmaking",
new MatchmakingStateDto("searching", n, null));
}
/// <summary>Client safety net: re-send the current game state to a player who
/// may have missed the initial broadcast (green-felt freeze guard).</summary>
+11 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Crown, Loader2 } from "lucide-react";
import { Crown, Loader2, Users } from "lucide-react";
import { useEffect, useState } from "react";
import { ScreenShell } from "@/components/online/ScreenHeader";
import { Avatar } from "@/components/online/Avatar";
@@ -140,6 +140,16 @@ export function MatchmakingScreen() {
{searching && (
<>
<div className="mt-2 text-3xl font-black gold-text tabular-nums">{elapsed}s</div>
{(mm.waiting ?? 0) >= 2 && (
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="mt-2 inline-flex items-center gap-1.5 rounded-full glass gold-border px-3 py-1 text-xs font-bold text-teal-300"
>
<Users className="size-3.5" />
{t("mm.inQueue", { n: mm.waiting! })}
</motion.div>
)}
{elapsed >= 40 ? (
<p className="text-rose-300 text-xs mt-2 max-w-[18rem]">{t("mm.stuck")}</p>
) : (
+2
View File
@@ -256,6 +256,7 @@ const fa: Dict = {
"mm.found": "بازیکنان پیدا شدند!",
"mm.ready": "آماده شروع",
"mm.fillHint": "منتظر پیوستن یک بازیکن آنلاین هستیم… می‌توانی همین حالا با ربات شروع کنی",
"mm.inQueue": "{n} بازیکن در صف",
"mm.stuck": "اتصال به سرور طول کشید. لطفاً «لغو» را بزنید و دوباره تلاش کنید.",
"intro.found": "بازیکنان آماده‌اند!",
"intro.getReady": "بازی در حال شروع است…",
@@ -644,6 +645,7 @@ const en: Dict = {
"mm.found": "Players found!",
"mm.ready": "Ready to start",
"mm.fillHint": "Waiting for an online player to join… or start now with bots",
"mm.inQueue": "{n} players in queue",
"mm.stuck": "Connecting to the server is taking too long. Tap Cancel and try again.",
"mm.cancel": "Cancel",
"mm.start": "Enter game",
+3 -2
View File
@@ -160,7 +160,7 @@ export class SignalrService implements OnlineService {
.build();
conn.on("matchmaking", (s: { phase: string; players: number; queuePosition: number | null }) =>
this.emitMM(s.phase, s.queuePosition ?? undefined));
this.emitMM(s.phase, s.queuePosition ?? undefined, s.players));
conn.on("matchFound", () => {
this.emitMM("ready");
// Safety net: if the initial state never lands (dropped/raced), ask the
@@ -226,7 +226,7 @@ export class SignalrService implements OnlineService {
}
}
private emitMM(phase: string, queuePosition?: number) {
private emitMM(phase: string, queuePosition?: number, waiting?: number) {
const state: MatchmakingState = {
phase: phase as MatchmakingState["phase"],
players: [],
@@ -234,6 +234,7 @@ export class SignalrService implements OnlineService {
ranked: this.mmRanked,
stake: this.mmStake,
queuePosition,
waiting,
};
this.mmCbs.forEach((cb) => cb(state));
}
+2
View File
@@ -390,6 +390,8 @@ export interface MatchmakingState {
elapsedMs: number;
ranked: boolean;
stake: number;
/** live count of real humans waiting in the matchmaking queue (incl. you). */
waiting?: number;
/** position in the queue when phase === "queued" */
queuePosition?: number;
}