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
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:
+18
-105
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user