feat(mm): wait longer for a real opponent; add "start with bots now"
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 34s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m9s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m8s

- 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:
soroush.asadi
2026-06-16 22:12:48 +03:30
parent 9901c5e6d4
commit c0e3fdb046
7 changed files with 100 additions and 13 deletions
+23 -2
View File
@@ -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 && (
<>
<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-cream/50 text-xs mt-1 max-w-[16rem]">{t("mm.fillHint")}</p>
@@ -185,10 +194,22 @@ export function MatchmakingScreen() {
})}
</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">
{t("mm.cancel")}
</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 && (
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
+4 -2
View File
@@ -255,13 +255,14 @@ const fa: Dict = {
"mm.searching": "در حال یافتن حریف…",
"mm.found": "بازیکنان پیدا شدند!",
"mm.ready": "آماده شروع",
"mm.fillHint": "اگر بازیکن آنلاینی پیدا نشود، ربات‌ها جایگزین می‌شوند",
"mm.fillHint": "منتظر پیوستن یک بازیکن آنلاین هستیم… می‌توانی همین حالا با ربات شروع کنی",
"mm.stuck": "اتصال به سرور طول کشید. لطفاً «لغو» را بزنید و دوباره تلاش کنید.",
"intro.found": "بازیکنان آماده‌اند!",
"intro.getReady": "بازی در حال شروع است…",
"intro.go": "شروع!",
"mm.cancel": "لغو",
"mm.start": "ورود به بازی",
"mm.playNow": "شروع با ربات",
"lead.title": "جدول امتیازات",
"lead.rank": "رتبه",
@@ -642,10 +643,11 @@ const en: Dict = {
"mm.searching": "Searching for opponents…",
"mm.found": "Players found!",
"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.cancel": "Cancel",
"mm.start": "Enter game",
"mm.playNow": "Start with bots",
"lead.title": "Leaderboard",
"lead.rank": "Rank",
+29
View File
@@ -984,6 +984,35 @@ export class MockOnlineService implements OnlineService {
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 {
this.mmCbs.add(cb);
return () => this.mmCbs.delete(cb);
+2
View File
@@ -132,6 +132,8 @@ export interface OnlineService {
/* ----- matchmaking ----- */
startMatchmaking(opts: MatchmakingOptions): 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;
/* ----- match players (for the online game driver) ----- */
+4
View File
@@ -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);