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:
+31
-9
@@ -79,7 +79,7 @@
|
||||
let recentFlash = $state(false);
|
||||
function refreshRecent() {
|
||||
const v = view;
|
||||
if (!v || v.game.status !== 'active') {
|
||||
if (!v || v.game.status === 'finished') {
|
||||
recent = new Set();
|
||||
recentFlash = false;
|
||||
return;
|
||||
@@ -98,8 +98,12 @@
|
||||
});
|
||||
const slots = $derived(rackView(placement));
|
||||
const rackSlots = $derived(slots.map((s) => ({ ...s, id: rackIds[s.index] ?? s.index })));
|
||||
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||
const gameOver = $derived(!!view && view.game.status !== 'active');
|
||||
// 'open' is an auto-match game still waiting for an opponent: the starter may move on their
|
||||
// turn just like an active game, so "playable" covers both; only 'finished' is over.
|
||||
const waitingForOpponent = $derived(!!view && view.game.status === 'open');
|
||||
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
||||
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
||||
const gameOver = $derived(!!view && view.game.status === 'finished');
|
||||
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
|
||||
// The seat whose move the history grid awaits with a "thinking…" placeholder: the player to
|
||||
// move while the game is active, but never the viewer themselves (their own pending cell
|
||||
@@ -215,6 +219,13 @@
|
||||
if (view && e.moveCount > view.game.moveCount) void load();
|
||||
} else if (e.kind === 'game_over' && e.gameId === id) {
|
||||
applyDelta(applyGameOver(cacheSnapshot(), e.game));
|
||||
} else if (e.kind === 'opponent_joined' && e.gameId === id && e.state) {
|
||||
// The opponent took the empty seat: adopt the new participants and status in place,
|
||||
// leaving the board, rack and any pending placement untouched (no refetch, no flicker).
|
||||
if (view) {
|
||||
view = { ...view, game: { ...view.game, seats: e.state.game.seats, status: e.state.game.status, players: e.state.game.players } };
|
||||
setCachedGame(id, view, moves);
|
||||
}
|
||||
} else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) {
|
||||
// A request the player sent was answered: re-derive the in-game "add friend" state.
|
||||
void loadFriends();
|
||||
@@ -748,10 +759,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is
|
||||
// not yet a friend (an already-requested opponent still shows it, but disabled).
|
||||
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
|
||||
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
|
||||
// display name.
|
||||
function seatName(s: { accountId: string; displayName: string } | undefined): string {
|
||||
if (!s) return '';
|
||||
if (s.accountId === app.session?.userId) return t('common.you');
|
||||
if (!s.accountId) return t('game.searchingForOpponent');
|
||||
return s.displayName;
|
||||
}
|
||||
|
||||
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing a seated opponent
|
||||
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
||||
// opponent still shows it, but disabled).
|
||||
function canAddFriend(accountId: string): boolean {
|
||||
return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
|
||||
return !!accountId && !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -763,7 +785,7 @@
|
||||
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
|
||||
<div class="nm">{seatName(s)}</div>
|
||||
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
|
||||
{#if historyOpen && canAddFriend(s.accountId)}
|
||||
<span class="addfriend">
|
||||
@@ -788,7 +810,7 @@
|
||||
{#if gameOver}
|
||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||
{:else}
|
||||
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button>
|
||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||
@@ -855,7 +877,7 @@
|
||||
{#if gameOver}
|
||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : seatName(view.game.seats[view.game.toMove])}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
|
||||
|
||||
Reference in New Issue
Block a user