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:
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// The quick-match flow drops the player straight into a game that is still waiting for an
|
||||
// opponent (status 'open'): the opponent card shows "searching for opponent" and resign is
|
||||
// disabled until the mock attaches a robot shortly after, which restores the game UI. Driven
|
||||
// entirely by the mock transport (no backend).
|
||||
|
||||
test('quick game: enter immediately, wait for an opponent, then it joins', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /New/ }).click(); // lobby tab bar -> auto-match
|
||||
|
||||
// Pick a variant and start; the player lands in the game at once (no "searching" screen).
|
||||
await page.locator('.variant').first().click();
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
|
||||
// Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the
|
||||
// history panel) is disabled.
|
||||
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
|
||||
await page.locator('.scoreboard').click(); // open the history panel
|
||||
await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled();
|
||||
|
||||
// Attach the opponent deterministically (the mock otherwise joins on a timer).
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
|
||||
// The opponent card shows its name, the placeholder is gone, and resign is enabled again.
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
|
||||
});
|
||||
@@ -8,6 +8,7 @@
|
||||
myId,
|
||||
busy,
|
||||
myTurn = false,
|
||||
waiting = false,
|
||||
nudgeOnCooldown = false,
|
||||
onsend,
|
||||
onnudge,
|
||||
@@ -20,6 +21,9 @@
|
||||
// hurry); on the opponent's turn only the nudge button shows. While the hourly nudge
|
||||
// cooldown is active the nudge is disabled with an "awaiting reply" caption.
|
||||
myTurn?: boolean;
|
||||
// waiting is true while an auto-match game still has no opponent: both send and nudge
|
||||
// are disabled (there is no one to message or hurry yet).
|
||||
waiting?: boolean;
|
||||
nudgeOnCooldown?: boolean;
|
||||
onsend: (text: string) => void;
|
||||
onnudge: () => void;
|
||||
@@ -56,11 +60,11 @@
|
||||
bind:value={text}
|
||||
onkeydown={(e) => e.key === 'Enter' && send()}
|
||||
/>
|
||||
<button class="iconbtn" onclick={send} disabled={busy || !connection.online} aria-label={t('chat.send')}>⬆️</button>
|
||||
<button class="iconbtn" onclick={send} disabled={busy || waiting || !connection.online} aria-label={t('chat.send')}>⬆️</button>
|
||||
{:else}
|
||||
<!-- A flex:1 caption keeps the nudge pinned right whether or not the cooldown text shows. -->
|
||||
<span class="cooldown">{nudgeOnCooldown ? t('chat.awaitingReply') : ''}</span>
|
||||
<button class="iconbtn" onclick={onnudge} disabled={busy || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
||||
<button class="iconbtn" onclick={onnudge} disabled={busy || waiting || nudgeOnCooldown || !connection.online} aria-label={t('chat.nudgeAction')}>🛎️</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
let tick = $state(0);
|
||||
|
||||
const myId = $derived(app.session?.userId ?? '');
|
||||
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
|
||||
const isMyTurn = $derived(
|
||||
!!view && (view.game.status === 'active' || view.game.status === 'open') && view.game.toMove === view.seat,
|
||||
);
|
||||
// While the auto-match game still has no opponent, chat and nudge are both disabled.
|
||||
const waiting = $derived(!!view && view.game.status === 'open');
|
||||
const nudgeCooldownSecs = 3600;
|
||||
// The nudge is one-per-hour-per-game and clears once the player chats (engagement); the
|
||||
// backend stays authoritative, so a move-based reset is left to it.
|
||||
@@ -87,4 +91,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
|
||||
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {waiting} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
|
||||
|
||||
+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}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as fb from '../gen/fbs/scrabblefb';
|
||||
import { BLANK_INDEX, setAlphabet } from './alphabet';
|
||||
import {
|
||||
decodeDraftView,
|
||||
decodeEvent,
|
||||
decodeFriendList,
|
||||
decodeGameList,
|
||||
decodeInvitation,
|
||||
@@ -264,6 +265,19 @@ describe('codec', () => {
|
||||
expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' });
|
||||
expect(inv.variant).toBe('scrabble_en');
|
||||
});
|
||||
|
||||
it('decodes an opponent_joined event (reusing the match_found payload layout)', () => {
|
||||
const b = new Builder(64);
|
||||
const gid = b.createString('g-open');
|
||||
fb.MatchFoundEvent.startMatchFoundEvent(b);
|
||||
fb.MatchFoundEvent.addGameId(b, gid);
|
||||
b.finish(fb.MatchFoundEvent.endMatchFoundEvent(b));
|
||||
expect(decodeEvent('opponent_joined', b.asUint8Array())).toEqual({
|
||||
kind: 'opponent_joined',
|
||||
gameId: 'g-open',
|
||||
state: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The live play loop exchanges alphabet indices, mapped through the per-variant
|
||||
|
||||
@@ -465,6 +465,12 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null
|
||||
const st = e.state();
|
||||
return { kind: 'match_found', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
|
||||
}
|
||||
case 'opponent_joined': {
|
||||
// opponent_joined reuses the match_found payload layout (game id + the recipient's state).
|
||||
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
|
||||
const st = e.state();
|
||||
return { kind: 'opponent_joined', gameId: s(e.gameId()), state: st ? decodeStateViewTable(st) : undefined };
|
||||
}
|
||||
case 'notify': {
|
||||
const e = fb.NotificationEvent.getRootAsNotificationEvent(bb);
|
||||
const acc = e.account();
|
||||
|
||||
@@ -20,4 +20,9 @@ if (isMock && typeof window !== 'undefined') {
|
||||
offline: reportOffline,
|
||||
online: reportOnline,
|
||||
};
|
||||
// Drive the auto-match opponent join deterministically from the e2e (the mock otherwise
|
||||
// attaches a robot on a timer).
|
||||
(window as unknown as { __mock?: { joinOpponent(): void } }).__mock = {
|
||||
joinOpponent: () => (gateway as MockGateway).joinPendingOpponent(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,11 +51,14 @@ export const en = {
|
||||
'new.rulesRussian': '104 tiles · ё is a letter · bingo +50',
|
||||
'new.rulesErudit': '131 tiles · ё = е · no centre ×2 · bonus +15',
|
||||
'new.moveLimit': 'Move time: {n} h 00 min',
|
||||
'new.searchHint':
|
||||
'Finding an opponent can sometimes take a while. If you do not want to wait, close the app after starting the game and come back in a couple of minutes.',
|
||||
|
||||
'game.bag': '{n} in the bag',
|
||||
'game.bagEmpty': 'Bag is empty',
|
||||
'game.hints': 'Hints {n}',
|
||||
'game.yourTurn': 'Your turn',
|
||||
'game.searchingForOpponent': 'Searching for opponent…',
|
||||
'game.waiting': "Waiting for {name}",
|
||||
'game.makeMove': 'Make move',
|
||||
'game.reset': 'Reset',
|
||||
|
||||
@@ -52,11 +52,14 @@ export const ru: Record<MessageKey, string> = {
|
||||
'new.rulesRussian': '104 фишки · ё — отдельная буква · бинго +50',
|
||||
'new.rulesErudit': '131 фишка · ё = е · центр не удваивает · бонус +15',
|
||||
'new.moveLimit': 'Время на ход: {n} ч. 00 мин.',
|
||||
'new.searchHint':
|
||||
'Иногда поиск соперника может занимать некоторое время. Если не хотите ждать, после начала игры закройте приложение и возвращайтесь через пару минут.',
|
||||
|
||||
'game.bag': '{n} в мешке',
|
||||
'game.bagEmpty': 'Мешок пуст',
|
||||
'game.hints': 'Подсказки {n}',
|
||||
'game.yourTurn': 'Ваш ход',
|
||||
'game.searchingForOpponent': 'Поиск соперника...',
|
||||
'game.waiting': 'Ожидаем {name}',
|
||||
'game.makeMove': 'Сделать ход',
|
||||
'game.reset': 'Сброс',
|
||||
|
||||
@@ -88,6 +88,8 @@ export class MockGateway implements GatewayClient {
|
||||
private readonly profile: Profile = { ...PROFILE };
|
||||
private readonly subs = new Set<(e: PushEvent) => void>();
|
||||
private pendingMatch: string | null = null;
|
||||
// The most recently opened auto-match game still awaiting an opponent, for the e2e join hook.
|
||||
private openGameId: string | null = null;
|
||||
private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f }));
|
||||
private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f }));
|
||||
private outgoing: AccountRef[] = [];
|
||||
@@ -143,14 +145,16 @@ export class MockGateway implements GatewayClient {
|
||||
|
||||
// --- lobby ---
|
||||
async lobbyEnqueue(variant: Variant, multipleWords: boolean): Promise<MatchResult> {
|
||||
// Simulate a 10s-style robot substitution, sped up: match found shortly.
|
||||
// The player enters an open game immediately and waits inside it; a robot opponent takes
|
||||
// the empty seat shortly (a sped-up version of the backend's 90–180 s wait), pushing
|
||||
// opponent_joined so the game UI restores from the "searching for opponent" state.
|
||||
const id = crypto.randomUUID();
|
||||
const g: MockGame = {
|
||||
view: {
|
||||
id,
|
||||
variant,
|
||||
dictVersion: 'v1',
|
||||
status: 'active',
|
||||
status: 'open',
|
||||
players: 2,
|
||||
toMove: 0,
|
||||
turnTimeoutSecs: 86400,
|
||||
@@ -160,7 +164,7 @@ export class MockGateway implements GatewayClient {
|
||||
lastActivityUnix: Math.floor(Date.now() / 1000),
|
||||
seats: [
|
||||
{ seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false },
|
||||
{ seat: 1, accountId: '', displayName: '', score: 0, hintsUsed: 0, isWinner: false },
|
||||
],
|
||||
},
|
||||
moves: [],
|
||||
@@ -170,9 +174,28 @@ export class MockGateway implements GatewayClient {
|
||||
chat: [],
|
||||
};
|
||||
this.games.set(id, g);
|
||||
this.pendingMatch = id;
|
||||
setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400);
|
||||
return { matched: false };
|
||||
this.openGameId = id;
|
||||
// The opponent joins on a timer for manual mock play; the e2e triggers it deterministically
|
||||
// through the __mock hook (see lib/gateway.ts).
|
||||
setTimeout(() => this.fillOpponent(id), 3000);
|
||||
return { matched: false, game: structuredClone(g.view) };
|
||||
}
|
||||
|
||||
// fillOpponent seats a robot in an open game's empty seat and pushes opponent_joined — the
|
||||
// mock of a human or robot taking the seat. A no-op once the game is no longer open.
|
||||
private fillOpponent(id: string): void {
|
||||
const game = this.games.get(id);
|
||||
if (!game || game.view.status !== 'open') return;
|
||||
game.view.status = 'active';
|
||||
game.view.seats[1] = { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false };
|
||||
this.emit({ kind: 'opponent_joined', gameId: id, state: this.stateOf(game) });
|
||||
}
|
||||
|
||||
// joinPendingOpponent is the e2e hook (exposed as window.__mock.joinOpponent) to attach the
|
||||
// opponent to the most recently opened game on demand, making the waiting → joined transition
|
||||
// deterministic.
|
||||
joinPendingOpponent(): void {
|
||||
if (this.openGameId) this.fillOpponent(this.openGameId);
|
||||
}
|
||||
|
||||
async lobbyPoll(): Promise<MatchResult> {
|
||||
@@ -190,8 +213,8 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- game ---
|
||||
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
|
||||
const g = this.game(gameId);
|
||||
// stateOf builds a player's StateView from a mock game (the viewer is always ME).
|
||||
private stateOf(g: MockGame): StateView {
|
||||
return {
|
||||
game: structuredClone(g.view),
|
||||
seat: this.mySeat(g),
|
||||
@@ -201,6 +224,10 @@ export class MockGateway implements GatewayClient {
|
||||
};
|
||||
}
|
||||
|
||||
async gameState(gameId: string, _includeAlphabet: boolean): Promise<StateView> {
|
||||
return this.stateOf(this.game(gameId));
|
||||
}
|
||||
|
||||
async gameHistory(gameId: string): Promise<History> {
|
||||
const g = this.game(gameId);
|
||||
return { gameId, moves: structuredClone(g.moves) };
|
||||
|
||||
+6
-4
@@ -5,8 +5,9 @@
|
||||
|
||||
export type Variant = 'scrabble_en' | 'scrabble_ru' | 'erudit_ru';
|
||||
|
||||
/** Backend game status strings. */
|
||||
export type GameStatus = 'active' | 'finished' | string;
|
||||
/** Backend game status strings. 'open' is an auto-match game the player has entered
|
||||
* but which is still waiting for an opponent (the opponent seat has no account). */
|
||||
export type GameStatus = 'active' | 'finished' | 'open' | string;
|
||||
|
||||
/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */
|
||||
export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string;
|
||||
@@ -237,8 +238,8 @@ export interface GameList {
|
||||
/**
|
||||
* A live event delivered over the Subscribe stream. The game events carry the move as a
|
||||
* delta — move plus the post-move summary (and the bag size) — the client applies to its
|
||||
* cached game without a refetch; match_found / game_started carry the recipient's initial
|
||||
* StateView; notify carries the changed lobby payload. The enriched fields are optional
|
||||
* cached game without a refetch; match_found / game_started / opponent_joined carry the
|
||||
* recipient's StateView; notify carries the changed lobby payload. The enriched fields are optional
|
||||
* so a client falls back to a refetch when a payload is absent (a gap, or an older peer).
|
||||
*/
|
||||
export type PushEvent =
|
||||
@@ -248,5 +249,6 @@ export type PushEvent =
|
||||
| { kind: 'chat_message'; message: ChatMessage }
|
||||
| { kind: 'nudge'; gameId: string; fromUserId: string }
|
||||
| { kind: 'match_found'; gameId: string; state?: StateView }
|
||||
| { kind: 'opponent_joined'; gameId: string; state?: StateView }
|
||||
| { kind: 'notify'; sub: string; account?: AccountRef; invitation?: Invitation; state?: StateView }
|
||||
| { kind: 'heartbeat' };
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
const groups = $derived(groupGames(games, myId));
|
||||
|
||||
function opponents(g: GameView): string {
|
||||
// An auto-match game still waiting for an opponent shows the "searching" placeholder.
|
||||
if (g.status === 'open') return t('game.searchingForOpponent');
|
||||
return g.seats
|
||||
.filter((s) => s.accountId !== myId)
|
||||
.map((s) => s.displayName)
|
||||
|
||||
+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