feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s

Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place.

Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent".

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
This commit is contained in:
Ilia Denisov
2026-06-12 16:00:22 +02:00
parent 10dc1f0d48
commit c305363ccd
42 changed files with 1248 additions and 768 deletions
+18 -105
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
@@ -41,79 +41,25 @@
let mode = $state<'auto' | 'friends'>('auto');
// --- auto-match ---
let searching = $state(false);
// matched guards the teardown: once a match arrives (immediately, via the match_found push, or
// via the fallback poll) onDestroy must not dequeue the game we just got.
let matched = $state(false);
let poll: ReturnType<typeof setInterval> | null = null;
function stop() {
if (poll) {
clearInterval(poll);
poll = null;
}
}
// startPoll is the matchmaking fallback used only while the live stream is down: with the stream
// up the match_found push drives navigation. It polls lobby.poll every 2.5s.
function startPoll() {
if (poll) return;
poll = setInterval(async () => {
try {
const p = await gateway.lobbyPoll();
if (p.matched && p.game) {
matched = true;
searching = false;
stop();
navigate(`/game/${p.game.id}`);
}
} catch (e) {
handleError(e);
}
}, 2500);
}
// cancelSearch leaves the auto-match pool on the backend too, so a cancelled quick-match
// is actually dequeued — otherwise the account stays queued (blocking a re-queue) and the
// reaper later substitutes a robot for a game the player abandoned.
function cancelSearch() {
stop();
searching = false;
void gateway.lobbyCancel().catch(() => {});
navigate('/');
}
// Enqueue drops the player straight into a real game — a freshly opened one awaiting an
// opponent, or another player's open game they just joined — so we navigate into it at once
// and the player waits inside. The opponent (a human or, after the wait, a robot) takes the
// empty seat later via the opponent_joined push; there is no separate "searching" screen.
let starting = $state(false);
async function find(v: Variant) {
searching = true;
matched = false;
if (starting) return;
starting = true;
try {
const r = await gateway.lobbyEnqueue(v, multipleWordsForRequest(v, multipleWords));
if (r.matched && r.game) {
matched = true;
searching = false;
navigate(`/game/${r.game.id}`);
}
if (r.game) navigate(`/game/${r.game.id}`);
} catch (e) {
searching = false;
handleError(e);
} finally {
starting = false;
}
// No immediate match: wait for the match_found push; the effect below polls only when the
// stream is down.
}
// Poll for the match only while searching and the stream is down (the push cannot reach us);
// stop once the stream is back or the search ends.
$effect(() => {
if (searching && !app.streamAlive) startPoll();
else stop();
});
// match_found is handled globally (app.svelte navigates); end the search here too so onDestroy
// does not cancel the match we just received.
$effect(() => {
if (app.lastEvent?.kind === 'match_found' && searching) {
matched = true;
searching = false;
}
});
// --- friend game ---
let friends = $state<AccountRef[]>([]);
let selected = $state<string[]>([]);
@@ -161,23 +107,10 @@
}
}
onDestroy(() => {
stop();
// Abandoned mid-search without a match (navigated away without Cancel): dequeue so we don't
// linger. A received match (matched) must not be cancelled.
if (searching && !matched) void gateway.lobbyCancel().catch(() => {});
});
</script>
<Screen title={t('new.title')} back="/">
<div class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={cancelSearch}>{t('common.cancel')}</button>
</div>
{:else}
{#if !guest}
<div class="seg modes">
<button class="opt" class:active={mode === 'auto'} onclick={() => (mode = 'auto')}>{t('new.auto')}</button>
@@ -214,9 +147,10 @@
</label>
{/if}
<p class="movelimit">{t('new.moveLimit', { n: AUTO_MATCH_HOURS })}</p>
<p class="searchhint">{t('new.searchHint')}</p>
<button
class="invite"
disabled={!selectedAuto || !connection.online}
disabled={!selectedAuto || !connection.online || starting}
onclick={() => selectedAuto && find(selectedAuto)}
>{t('new.start')}</button>
{:else if friends.length === 0}
@@ -266,7 +200,6 @@
<button class="invite" disabled={selected.length === 0 || !inviteVariant || !connection.online} onclick={sendInvite}>{t('new.invite')}</button>
</div>
{/if}
{/if}
</div>
</Screen>
@@ -460,31 +393,11 @@
.invite:disabled {
opacity: 0.5;
}
.searching {
display: grid;
place-items: center;
gap: 14px;
padding: 48px 0;
.searchhint {
margin: 0;
text-align: center;
color: var(--text-muted);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.cancel {
padding: 8px 16px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 0.85rem;
line-height: 1.4;
}
</style>