mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
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:
1 parent
4a78b8aad5
commit
e6f2d1922c
7 files changed
+228
-39
No files matched your search
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user