feat(web): a play-now button that skips the room code

QuickMatch/CancelQuickMatch join and leave the pairing queue; the
store tracks the wait as `queued` and clears it the moment a RoomState
seats the connection. The /online screen shows a waiting panel with
elapsed time, a cancel button, and — past 20 seconds — a nudge toward
the bot; leaving the page while queued cancels it. Adds the two new
error codes and a sentence to the room-code rules copy.
This commit is contained in:
tiennm99 committed 2026-09-21 01:39:54 +07:00
1 parent 4a78b8aad5
commit e6f2d1922c
7 files changed
+228 -39

No files matched your search

+3
View File
@@ -60,6 +60,9 @@ rather than against each other's assumptions. Regenerate the fixtures with
A player creates a room and gets a six-character code and an invite link. The
alphabet omits `0`/`O` and `1`/`I`/`L`, because these codes get read aloud. The
others type the code or open the link, which seats them in the room's lobby.
Quick-match is the other way in: it pairs a player with the next stranger who
also asked, with no code to share, and the first game between them starts by
itself rather than waiting on readiness and a start button.
A room holds up to four people and needs two to start. Both numbers are server
constants sent to the client in `RoomState`, so the lobby draws whatever the
+11 -1
View File
@@ -89,6 +89,14 @@ export const t = {
onlineIntro: 'Tạo phòng rồi gửi mã cho bạn bè, hoặc nhập mã bạn được mời.',
createRoom: 'Tạo phòng',
joinRoom: 'Vào phòng',
// Quick-match: paired with the next stranger who also asked, no code
// exchanged.
quickMatch: 'Chơi ngay',
quickMatchWaiting: 'Đang tìm đối thủ… {n} giây',
quickMatchCancel: 'Hủy tìm trận',
quickMatchNudge: 'Chưa có ai chờ…',
quickMatchNudgeLink: 'Chơi với máy trong lúc đợi?',
roomCodeLabel: 'Mã phòng',
roomCodePlaceholder: 'VD: K7M2QP',
roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.',
@@ -192,7 +200,7 @@ export const t = {
rulesRoomTitle: 'Mã phòng và sẵn sàng',
rulesRoomBody:
'Người tạo phòng nhận một mã sáu ký tự để gửi cho bạn bè — bảng chữ cái của mã bỏ 0/O và 1/I/L vì mã này thường được đọc thành tiếng cho nhau nghe. Phòng cần mọi người trừ chủ phòng bấm sẵn sàng rồi chủ phòng mới bấm bắt đầu — chủ phòng không có nút sẵn sàng riêng, vì bấm bắt đầu đã là lời xác nhận của họ.',
'Người tạo phòng nhận một mã sáu ký tự để gửi cho bạn bè — bảng chữ cái của mã bỏ 0/O và 1/I/L vì mã này thường được đọc thành tiếng cho nhau nghe. Phòng cần mọi người trừ chủ phòng bấm sẵn sàng rồi chủ phòng mới bấm bắt đầu — chủ phòng không có nút sẵn sàng riêng, vì bấm bắt đầu đã là lời xác nhận của họ. Không có ai để gửi mã thì bấm "Chơi ngay": bạn được ghép với người lạ kế tiếp cũng đang chờ, và ván đầu tiên tự bắt đầu, không cần ai bấm sẵn sàng hay bắt đầu.',
rulesReconnectTitle: 'Mất kết nối',
rulesReconnectBody:
@@ -248,6 +256,8 @@ export const endReasonMessages = {
*/
export const errorMessages = {
already_greeted: 'Phiên chơi đã được mở rồi.',
already_in_a_room: 'Bạn đang ở trong một phòng khác.',
already_queued: 'Bạn đang chờ ghép trận rồi.',
bad_frame: 'Máy chủ không đọc được dữ liệu gửi lên.',
busy: 'Máy chủ đang bận. Hãy thử lại.',
cannot_join_own_room: 'Bạn không thể vào phòng của chính mình.',
+14
View File
@@ -76,6 +76,13 @@ function initialState() {
nickname: '',
roomCode: '',
/**
* Waiting in the quick-match queue for the next stranger who also
* asked. Ends on its own once a `RoomState` seats this connection
* somewhere, so nothing else has to clear it by hand.
*/
queued: false,
/**
* The room, exactly as the server last described it. Every field is
* server-owned: the client never decides who is seated, who owns the
@@ -246,6 +253,9 @@ export function createGameStore() {
break;
case 'roomState':
// A room existing is proof the wait is over, whether or not a
// quickMatchStatus already said so.
state.queued = false;
// One snapshot, applied wholesale. Merging fields selectively
// is how a client ends up believing a mixture of two states
// the server was never in.
@@ -418,6 +428,10 @@ export function createGameStore() {
state.error = errorMessage(value.code);
break;
case 'quickMatchStatus':
state.queued = value.queued;
break;
case 'pong':
// Handled by the transport, which owns the clock offset.
break;
+20
View File
@@ -1,5 +1,6 @@
import { create } from '@bufbuild/protobuf';
import {
CancelQuickMatchSchema,
ClientMessageSchema,
CreateRoomSchema,
HelloSchema,
@@ -7,6 +8,7 @@ import {
KickPlayerSchema,
LeaveRoomSchema,
PingSchema,
QuickMatchSchema,
ResignSchema,
SendChatSchema,
SetReadySchema,
@@ -130,6 +132,24 @@ export function resign() {
});
}
/**
* Asks to be paired with the next stranger who also asks. Answered with
* QuickMatchStatus either way: queued while nobody else is waiting, or not
* once a room has been opened for the pair.
*/
export function quickMatch() {
return create(ClientMessageSchema, {
payload: { case: 'quickMatch', value: create(QuickMatchSchema, {}) }
});
}
/** Leaves the pairing queue. Safe to send whether or not it is still waiting. */
export function cancelQuickMatch() {
return create(ClientMessageSchema, {
payload: { case: 'cancelQuickMatch', value: create(CancelQuickMatchSchema, {}) }
});
}
/** @param {number} clientTimeMs */
export function ping(clientTimeMs) {
return create(ClientMessageSchema, {
+135 -37
View File
@@ -15,10 +15,12 @@
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
import {
cancelQuickMatch,
createRoom,
joinRoom,
kickPlayer,
leaveRoom,
quickMatch,
resign,
sendChat,
setReady,
@@ -39,10 +41,25 @@
* What the player asked for, held until the socket can carry it. Same shape
* as the bot screen's request latch and for the same reason: a request is
* something the player did, not a condition to be re-derived from the board.
* @type {{ kind: 'create' } | { kind: 'join', code: string } | null}
* @type {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' } | null}
*/
let pending = $state(null);
/**
* How long a quick match has been waiting, in whole seconds. Client-only
* and approximate on purpose — this is a "still looking" indicator, not
* the turn clock, so it is timed off the device rather than the server's
* estimated time.
*/
let queuedForS = $state(0);
/**
* How long the wait runs before the screen offers the bot instead. A
* quick match with nobody to pair with would otherwise be a dead end of
* its own.
*/
const QUICK_MATCH_NUDGE_S = 20;
/**
* How long a held request waits before the screen stops saying "connecting"
* and starts saying something the player can act on. The backoff is capped
@@ -175,6 +192,9 @@
send(leaveRoom());
forgetSession();
}
// Leaving mid-wait is leaving the queue too: nobody is left to pair
// with a tab that has gone.
if (game.state.queued) send(cancelQuickMatch());
pending = null;
disconnect();
game.reset();
@@ -188,6 +208,20 @@
untrack(() => flush(open));
});
// Counts up while queued, for the waiting panel's elapsed time and the
// bot nudge. Restarted from zero each time the wait begins, so a match
// found and then a later, separate wait never inherits the first one's
// clock.
$effect(() => {
if (!game.state.queued) {
queuedForS = 0;
return;
}
queuedForS = 0;
const id = setInterval(() => (queuedForS += 1), 1000);
return () => clearInterval(id);
});
// A request that has been waiting on a socket for longer than a player will
// believe. Timed from the request rather than from the status, because a
// backoff cycles between "reconnecting" and "connecting" indefinitely and
@@ -223,7 +257,7 @@
});
});
/** @param {{ kind: 'create' } | { kind: 'join', code: string }} req */
/** @param {{ kind: 'create' } | { kind: 'join', code: string } | { kind: 'quickMatch' }} req */
function request(req) {
codeError = '';
resuming = false;
@@ -244,7 +278,17 @@
if (!pending || !isOpen || resuming) return;
// Cleared only once the socket has taken it, so a request made during a
// reconnect is carried by the next open connection rather than lost.
const sent = pending.kind === 'create' ? send(createRoom()) : send(joinRoom(pending.code));
let sent;
switch (pending.kind) {
case 'create':
sent = send(createRoom());
break;
case 'quickMatch':
sent = send(quickMatch());
break;
default:
sent = send(joinRoom(pending.code));
}
if (sent) pending = null;
}
@@ -265,6 +309,16 @@
request({ kind: 'create' });
}
function playQuickMatch() {
request({ kind: 'quickMatch' });
}
/** Withdraws from the pairing queue without leaving the page. */
function cancelQueue() {
send(cancelQuickMatch());
pending = null;
}
function goHome() {
goto('/');
}
@@ -410,41 +464,61 @@
<p class="error" role="alert" data-testid="connect-stalled">{t.connectStalled}</p>
{/if}
<!-- Disabled while a request is in flight. Every impatient tap used to
send a real CreateRoom, and the fifth one came back as "you are
creating rooms too quickly" to a player who thought they had tapped
nothing at all. -->
<button type="button" class="primary" disabled={!!pending} onclick={create}>
{pending?.kind === 'create' ? t.connecting : t.createRoom}
</button>
<form
class="join"
onsubmit={(event) => {
event.preventDefault();
join();
}}
>
<label for="room-code">{t.roomCodeLabel}</label>
<div class="row">
<input
id="room-code"
type="text"
inputmode="text"
autocapitalize="characters"
autocomplete="off"
spellcheck="false"
maxlength={ROOM_CODE_LENGTH * 2}
placeholder={t.roomCodePlaceholder}
bind:value={codeInput}
oninput={() => (codeError = '')}
/>
<button type="submit" disabled={!!pending}>
{pending?.kind === 'join' ? t.connecting : t.joinRoom}
</button>
{#if game.state.queued}
<!-- The wait itself. Ends on its own — RoomState replaces this whole
branch the moment a match is found — so the only button here is
the way out. -->
<div class="waiting" role="status">
<p>{fill(t.quickMatchWaiting, { n: queuedForS })}</p>
<button type="button" onclick={cancelQueue}>{t.quickMatchCancel}</button>
{#if queuedForS >= QUICK_MATCH_NUDGE_S}
<p class="hint">
{t.quickMatchNudge}
<a href="/play">{t.quickMatchNudgeLink}</a>
</p>
{/if}
</div>
<p class="hint" class:invalid={codeError}>{codeError || t.roomCodeHint}</p>
</form>
{:else}
<!-- Disabled while a request is in flight. Every impatient tap used to
send a real CreateRoom, and the fifth one came back as "you are
creating rooms too quickly" to a player who thought they had tapped
nothing at all. -->
<button type="button" class="primary" disabled={!!pending} onclick={playQuickMatch}>
{pending?.kind === 'quickMatch' ? t.connecting : t.quickMatch}
</button>
<button type="button" class="primary" disabled={!!pending} onclick={create}>
{pending?.kind === 'create' ? t.connecting : t.createRoom}
</button>
<form
class="join"
onsubmit={(event) => {
event.preventDefault();
join();
}}
>
<label for="room-code">{t.roomCodeLabel}</label>
<div class="row">
<input
id="room-code"
type="text"
inputmode="text"
autocapitalize="characters"
autocomplete="off"
spellcheck="false"
maxlength={ROOM_CODE_LENGTH * 2}
placeholder={t.roomCodePlaceholder}
bind:value={codeInput}
oninput={() => (codeError = '')}
/>
<button type="submit" disabled={!!pending}>
{pending?.kind === 'join' ? t.connecting : t.joinRoom}
</button>
</div>
<p class="hint" class:invalid={codeError}>{codeError || t.roomCodeHint}</p>
</form>
{/if}
<a class="back" href="/">{t.back}</a>
{/if}
@@ -565,6 +639,30 @@
gap: 6px;
}
.waiting {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface-alt);
}
.waiting p {
margin: 0;
}
.waiting button {
min-height: 44px;
padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface);
font-weight: 600;
}
label {
font-weight: 600;
font-size: var(--text-5);
+22
View File
@@ -533,6 +533,28 @@ describe('pong', () => {
});
});
describe('quickMatchStatus', () => {
it('flags the wait while queued', () => {
const store = createGameStore();
store.apply(msg('quickMatchStatus', { queued: true }));
expect(store.state.queued).toBe(true);
});
it('ends the wait once the queue answers false', () => {
const store = createGameStore();
store.apply(msg('quickMatchStatus', { queued: true }));
store.apply(msg('quickMatchStatus', { queued: false }));
expect(store.state.queued).toBe(false);
});
it('ends the wait the moment a room seats this connection, even with no prior status', () => {
const store = createGameStore();
store.apply(msg('quickMatchStatus', { queued: true }));
store.apply(pair());
expect(store.state.queued).toBe(false);
});
});
describe('chat', () => {
/** @param {object} fields */
function line(fields) {
+23 -1
View File
@@ -7,7 +7,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { create, toBinary } from '@bufbuild/protobuf';
import { PROTOCOL_VERSION } from '../src/lib/ws/messages.js';
import { PROTOCOL_VERSION, cancelQuickMatch, quickMatch } from '../src/lib/ws/messages.js';
import {
ClientMessageSchema,
ServerMessageSchema
@@ -351,6 +351,28 @@ describe('frames', () => {
});
});
describe('quick match', () => {
it('encodes a QuickMatch frame the transport will carry as-is', async () => {
const h = setup();
h.client.connect();
h.last().open();
h.client.send(quickMatch());
const sent = await sentMessages(h.last());
expect(sent.at(-1).payload.case).toBe('quickMatch');
});
it('encodes a CancelQuickMatch frame the transport will carry as-is', async () => {
const h = setup();
h.client.connect();
h.last().open();
h.client.send(cancelQuickMatch());
const sent = await sentMessages(h.last());
expect(sent.at(-1).payload.case).toBe('cancelQuickMatch');
});
});
describe('handshake ordering', () => {
it('sends Hello before announcing the connection is open', async () => {
// The server refuses everything until the handshake lands, so a listener