feat(web): add online 1v1 play

Create a room and share a six-character code or an invite link; join by either.
The waiting room shows the code until an opponent arrives. During a game the
opponent's disconnection raises a banner with the grace countdown, and their
return clears it. When the game ends, the rematch prompt appears only while an
offer is actually open, so there is never a button that can only produce an
error.

The board is now one component both modes render, which is what makes online
play the same screen as the bot game rather than a second implementation of it.

Room codes are normalized by stripping separators and reading the code out of a
pasted invite link, and nothing else. Filtering out every character outside the
alphabet would turn any sentence into six plausible letters and send the player
to a room that was never theirs.

The socket now opens when the player asks for a room, not on arrival: the
handshake carries the nickname once, and a socket opened before they typed it
introduced them under whatever was stored beforehand. A tab that already holds
a session reconnects immediately instead, so refreshing mid-game returns to the
game. A resume the server cannot honour is dropped quietly rather than opening
the lobby with an error the player did nothing to cause.

The client also notices a socket that fails without closing. Nothing did
before, so the page kept showing a live connection and a running countdown over
a socket nothing could reach. A tick that fired late is ignored, because a
backgrounded tab has its timers throttled and would otherwise close a healthy
connection every time the player switched away.
This commit is contained in:
tiennm99 committed 2026-09-05 14:18:03 +07:00
1 parent 4277c2014c
commit ffa13a0f6e
23 files changed
+1369 -164

No files matched your search

+153
View File
@@ -0,0 +1,153 @@
<script>
import ChainHistory from '$lib/components/ChainHistory.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import CountdownRing from '$lib/components/CountdownRing.svelte';
import ScoreBoard from '$lib/components/ScoreBoard.svelte';
import WordInput from '$lib/components/WordInput.svelte';
import { t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
/**
* The board itself, shared by both modes. It renders the store and nothing
* else; what a finished game offers differs between bot and online play, so
* that arrives as a snippet rather than as a branch in here.
*
* @type {{
* opponentLabel: string,
* modeLabel?: string,
* onsubmit: (word: string) => boolean,
* onresign: () => void,
* gameOver: import('svelte').Snippet,
* banner?: import('svelte').Snippet
* }}
*/
let { opponentLabel, modeLabel = '', onsubmit, onresign, gameOver, banner } = $props();
</script>
<section class="board" data-phase={game.state.phase}>
<div class="top">
<ConnectionBadge />
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
</div>
<ScoreBoard {opponentLabel} />
{#if banner}{@render banner()}{/if}
{#if game.state.error}
<p class="error" role="alert">
{game.state.error}
<button type="button" onclick={() => game.clearError()} aria-label={t.dismiss}>×</button>
</p>
{/if}
{#if game.state.phase === 'over'}
{@render gameOver()}
{:else}
<div class="turn">
<CountdownRing />
<div class="prompt">
<p class="who" data-testid="turn-indicator">
{game.state.myTurn ? t.yourTurn : t.opponentTurn}
</p>
<p class="syllable">
<span class="label">{t.currentSyllable}</span>
<strong data-testid="current-syllable">{game.state.currentSyllable || '…'}</strong>
</p>
</div>
</div>
<WordInput {onsubmit} />
{/if}
<ChainHistory />
{#if game.state.phase === 'playing'}
<button type="button" class="resign" onclick={onresign}>{t.resign}</button>
{/if}
</section>
<style>
.board {
display: flex;
flex-direction: column;
flex: 1;
gap: 14px;
min-height: 0;
padding-bottom: 8px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mode {
color: var(--text-muted);
font-size: 0.85rem;
}
.turn {
display: flex;
align-items: center;
gap: 16px;
}
.prompt {
min-width: 0;
}
.who {
margin: 0 0 2px;
color: var(--text-muted);
font-size: 0.85rem;
}
.syllable {
display: flex;
flex-direction: column;
margin: 0;
}
.syllable .label {
color: var(--text-muted);
font-size: 0.75rem;
}
.syllable strong {
font-size: 1.6rem;
line-height: 1.2;
}
.error {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--danger-soft);
color: var(--danger);
font-size: 0.9rem;
}
.error button {
border: 0;
background: none;
font-size: 1.1rem;
line-height: 1;
}
.resign {
align-self: center;
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 0.85rem;
}
</style>
+10 -2
View File
@@ -2,7 +2,13 @@
import { endReasonMessages, t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
/** @type {{ isRecord: boolean, onrematch: () => void, onhome: () => void }} */
/**
* onrematch is optional because the two modes differ: a bot always plays
* again, so the button starts the next game, while online play has to ask
* the other person first and offers it from the rematch prompt instead.
*
* @type {{ isRecord: boolean, onrematch?: () => void, onhome: () => void }}
*/
let { isRecord, onrematch, onhome } = $props();
/** @type {{ iWon: boolean, reason: number, myScore: number, chainLength: number } | null} */
@@ -33,7 +39,9 @@
{/if}
<div class="actions">
<button type="button" class="primary" onclick={onrematch}>{t.rematch}</button>
{#if onrematch}
<button type="button" class="primary" onclick={onrematch}>{t.rematch}</button>
{/if}
<button type="button" onclick={onhome}>{t.home}</button>
</div>
</div>
@@ -0,0 +1,63 @@
<script>
import { fill, t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
/**
* The banner shown while an opponent is inside their reconnect window.
*
* The countdown is local because the server sends a duration, not a
* deadline: it is a rough "how much longer" for the player watching, and
* the server still decides when the seat is actually forfeit.
*/
let remainingMs = $state(0);
const away = $derived(game.state.opponentLeft);
$effect(() => {
const left = away;
if (!left?.canReconnect) {
remainingMs = 0;
return;
}
const endsAt = Date.now() + left.graceMs;
remainingMs = left.graceMs;
const tick = setInterval(() => {
remainingMs = Math.max(0, endsAt - Date.now());
if (remainingMs === 0) clearInterval(tick);
}, 250);
return () => clearInterval(tick);
});
const seconds = $derived(Math.ceil(remainingMs / 1000));
</script>
{#if away}
<p class="banner" class:gone={!away.canReconnect} role="status" data-testid="opponent-status">
{#if !away.canReconnect}
{t.opponentGone}
{:else if seconds > 0}
{fill(t.opponentDisconnectedIn, { n: seconds })}
{:else}
{t.opponentDisconnected}
{/if}
</p>
{/if}
<style>
.banner {
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--surface-alt);
color: var(--warn);
font-size: 0.9rem;
text-align: center;
}
.banner.gone {
color: var(--danger);
background: var(--danger-soft);
}
</style>
@@ -0,0 +1,87 @@
<script>
import { fill, t } from '$lib/i18n/vi.js';
import { game } from '$lib/stores/game.svelte.js';
/** @type {{ onaccept: () => void }} */
let { onaccept } = $props();
const offer = $derived(game.state.rematch);
// Counted down locally from the duration the server sent. The server owns
// the real expiry and closes the room when it passes; this only stops the
// prompt from claiming time that has already gone.
let remainingMs = $state(0);
$effect(() => {
const current = offer;
if (!current) {
remainingMs = 0;
return;
}
const endsAt = Date.now() + current.expiresInMs;
remainingMs = current.expiresInMs;
const tick = setInterval(() => {
remainingMs = Math.max(0, endsAt - Date.now());
if (remainingMs === 0) clearInterval(tick);
}, 250);
return () => clearInterval(tick);
});
const seconds = $derived(Math.ceil(remainingMs / 1000));
const expired = $derived(!!offer && remainingMs === 0);
</script>
{#if offer}
<div class="prompt" data-testid="rematch-prompt">
{#if expired}
<p class="line">{t.rematchExpired}</p>
{:else if offer.iAccepted}
<p class="line">{fill(t.rematchWaiting, { n: seconds })}</p>
{:else}
{#if offer.opponentAccepted}
<p class="line ready">{fill(t.rematchOpponentReady, { n: seconds })}</p>
{:else}
<p class="line">{t.rematchAsk}</p>
{/if}
<button type="button" onclick={onaccept} data-testid="rematch-accept">
{t.rematchYes}
</button>
{/if}
</div>
{/if}
<style>
.prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-alt);
}
.line {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
text-align: center;
}
.line.ready {
color: var(--accent);
font-weight: 600;
}
button {
padding: 10px 20px;
border: 0;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script>
import { t } from '$lib/i18n/vi.js';
/** @type {{ code: string }} */
let { code } = $props();
/** @type {'' | 'code' | 'link'} */
let copied = $state('');
/** @type {any} */
let clearTimer;
const inviteUrl = $derived(
typeof location === 'undefined' ? '' : `${location.origin}/online?code=${code}`
);
// Grouped in threes: the code exists to be read aloud down a phone line, and
// six unbroken letters are read back wrong.
const grouped = $derived(`${code.slice(0, 3)} ${code.slice(3)}`);
/**
* @param {'code' | 'link'} what
* @param {string} text
*/
async function copy(what, text) {
try {
await navigator.clipboard.writeText(text);
copied = what;
clearTimeout(clearTimer);
clearTimer = setTimeout(() => (copied = ''), 2000);
} catch {
// Clipboard access is refused outside a secure context and in some
// embedded browsers. The code is on screen either way, so there is
// nothing to recover — only a confirmation not to show.
}
}
async function share() {
try {
await navigator.share({ title: t.appName, text: t.shareInvite, url: inviteUrl });
} catch {
// Includes the user simply dismissing the sheet, which is not an error.
}
}
</script>
<div class="panel">
<p class="label">{t.yourRoomCode}</p>
<p class="code" data-testid="room-code" aria-label={code.split('').join(' ')}>{grouped}</p>
<div class="actions">
<button type="button" onclick={() => copy('code', code)}>
{copied === 'code' ? t.copied : t.copyCode}
</button>
<button type="button" onclick={() => copy('link', inviteUrl)}>
{copied === 'link' ? t.copied : t.copyLink}
</button>
{#if typeof navigator !== 'undefined' && 'share' in navigator}
<button type="button" onclick={share}>{t.shareLink}</button>
{/if}
</div>
</div>
<style>
.panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
.label {
margin: 0;
color: var(--text-muted);
font-size: 0.85rem;
}
.code {
margin: 0;
font-size: 2rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: 0.12em;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
.actions button {
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-alt);
font-size: 0.85rem;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
<script>
import RoomCodePanel from '$lib/components/RoomCodePanel.svelte';
import { t } from '$lib/i18n/vi.js';
/** @type {{ code: string, onleave: () => void }} */
let { code, onleave } = $props();
</script>
<section class="waiting">
<RoomCodePanel {code} />
<p class="status" role="status" data-testid="waiting-status">
<span class="spinner" aria-hidden="true"></span>
{t.waitingForOpponent}
</p>
<button type="button" class="leave" onclick={onleave}>{t.leaveRoom}</button>
</section>
<style>
.waiting {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.status {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
color: var(--text-muted);
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.leave {
padding: 10px 18px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-muted);
font-size: 0.9rem;
}
</style>
+26 -1
View File
@@ -56,7 +56,31 @@ export const t = {
themeDark: 'Tối',
onlineTitle: 'Đấu trực tuyến',
onlineComingSoon: 'Chế độ đấu 1v1 sẽ có ở bản cập nhật tiếp theo.',
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',
roomCodeLabel: 'Mã phòng',
roomCodePlaceholder: 'VD: K7M2QP',
roomCodeHint: 'Sáu ký tự. Không phân biệt hoa thường.',
roomCodeInvalid: 'Mã phòng gồm sáu ký tự.',
yourRoomCode: 'Mã phòng của bạn',
copyCode: 'Sao chép mã',
copyLink: 'Sao chép liên kết mời',
shareLink: 'Chia sẻ',
copied: 'Đã sao chép',
waitingForOpponent: 'Đang chờ đối thủ vào phòng…',
leaveRoom: 'Rời phòng',
shareInvite: 'Vào chơi nối từ với tôi',
opponentDisconnected: 'Đối thủ mất kết nối…',
opponentDisconnectedIn: 'Đối thủ mất kết nối… ({n}s)',
opponentGone: 'Đối thủ đã rời trận.',
rematchAsk: 'Chơi lại ván nữa?',
rematchYes: 'Chơi lại',
rematchWaiting: 'Đang chờ đối thủ đồng ý… ({n}s)',
rematchOpponentReady: 'Đối thủ muốn chơi lại. ({n}s)',
rematchExpired: 'Không còn ván nào nữa.',
attributionIntro: 'Từ điển dựa trên',
attributionSource: 'minhqnd/dictionary',
@@ -124,6 +148,7 @@ export const errorMessages = {
game_not_started: 'Ván đấu chưa bắt đầu.',
game_start_failed: 'Không thể bắt đầu ván đấu. Hãy thử lại.',
handshake_required: 'Phiên chơi chưa sẵn sàng. Hãy tải lại trang.',
no_rematch_offered: 'Chưa thể chơi lại lúc này.',
not_in_a_game: 'Bạn không ở trong ván đấu nào.',
not_your_seat: 'Bạn không phải người chơi trong ván này.',
protocol_version_mismatch: 'Phiên bản đã cũ. Hãy tải lại trang.',
+64
View File
@@ -135,6 +135,24 @@ export declare type Resign = Message<"noitu.v1.Resign"> & {
*/
export declare const ResignSchema: GenMessage<Resign>;
/**
* RequestRematch asks to play again in the same room after a game ends.
*
* There is no matching decline: leaving the room is the decline, and the
* server already learns about that from the socket closing. One message and
* one timeout cover every way a rematch does not happen.
*
* @generated from message noitu.v1.RequestRematch
*/
export declare type RequestRematch = Message<"noitu.v1.RequestRematch"> & {
};
/**
* Describes the message noitu.v1.RequestRematch.
* Use `create(RequestRematchSchema)` to create a new message.
*/
export declare const RequestRematchSchema: GenMessage<RequestRematch>;
/**
* Ping echoes the client clock so Pong can expose the offset between the two.
*
@@ -202,6 +220,12 @@ export declare type ClientMessage = Message<"noitu.v1.ClientMessage"> & {
*/
value: Ping;
case: "ping";
} | {
/**
* @generated from field: noitu.v1.RequestRematch request_rematch = 8;
*/
value: RequestRematch;
case: "requestRematch";
} | { case: undefined; value?: undefined };
};
@@ -549,6 +573,40 @@ export declare type Pong = Message<"noitu.v1.Pong"> & {
*/
export declare const PongSchema: GenMessage<Pong>;
/**
* RematchState is sent to both players whenever either one asks for a rematch,
* and once when the offer opens. Each recipient is told about their own side
* and their opponent's, so neither client has to work out which acceptance is
* whose.
*
* @generated from message noitu.v1.RematchState
*/
export declare type RematchState = Message<"noitu.v1.RematchState"> & {
/**
* @generated from field: bool i_accepted = 1;
*/
iAccepted: boolean;
/**
* @generated from field: bool opponent_accepted = 2;
*/
opponentAccepted: boolean;
/**
* How long is left to accept. The room closes when this runs out, which is
* also what a player who simply leaves ends up doing.
*
* @generated from field: uint32 expires_in_ms = 3;
*/
expiresInMs: number;
};
/**
* Describes the message noitu.v1.RematchState.
* Use `create(RematchStateSchema)` to create a new message.
*/
export declare const RematchStateSchema: GenMessage<RematchState>;
/**
* @generated from message noitu.v1.ServerMessage
*/
@@ -616,6 +674,12 @@ export declare type ServerMessage = Message<"noitu.v1.ServerMessage"> & {
*/
value: Pong;
case: "pong";
} | {
/**
* @generated from field: noitu.v1.RematchState rematch_state = 11;
*/
value: RematchState;
case: "rematchState";
} | { case: undefined; value?: undefined };
};
+29 -15
View File
@@ -21,7 +21,7 @@ import { enumDesc, fileDesc, messageDesc, tsEnum } from "@bufbuild/protobuf/code
* Describes the file noitu/v1/game.proto.
*/
export const file_noitu_v1_game = /*@__PURE__*/
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIrUCCg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIAEIJCgdwYXlsb2FkImgKB1dlbGNvbWUSEgoKc2Vzc2lvbl9pZBgBIAEoCRIUCgxyZXN1bWVfdG9rZW4YAiABKAkSGAoQcHJvdG9jb2xfdmVyc2lvbhgDIAEoDRIZChFhY2NlcHRlZF9uaWNrbmFtZRgEIAEoCSIgCgtSb29tQ3JlYXRlZBIRCglyb29tX2NvZGUYASABKAkiNgoKUm9vbUpvaW5lZBIRCglyb29tX2NvZGUYASABKAkSFQoNb3Bwb25lbnRfbmFtZRgCIAEoCSJbCgpQbGF5ZWRXb3JkEgwKBHdvcmQYASABKAkSDQoFYnlfbWUYAiABKAgSDgoGcG9pbnRzGAMgASgNEhEKCXN5bGxhYmxlcxgEIAEoDRINCgV0eXBlZBgFIAEoCSKRAQoLR2FtZVN0YXJ0ZWQSFAoMb3BlbmluZ193b3JkGAEgASgJEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhUKDXR1cm5fbGltaXRfbXMYBiABKA0iyQEKClR1cm5VcGRhdGUSJAoGcGxheWVkGAEgASgLMhQubm9pdHUudjEuUGxheWVkV29yZBIYChBjdXJyZW50X3N5bGxhYmxlGAIgASgJEg8KB215X3R1cm4YAyABKAgSGAoQZGVhZGxpbmVfdW5peF9tcxgEIAEoAxIQCgh0dXJuX3NlcRgFIAEoDRIQCghteV9zY29yZRgGIAEoDRIWCg5vcHBvbmVudF9zY29yZRgHIAEoDRIUCgxjaGFpbl9sZW5ndGgYCCABKA0iVgoMTW92ZVJlamVjdGVkEiYKBnJlYXNvbhgBIAEoDjIWLm5vaXR1LnYxLlJlamVjdFJlYXNvbhIMCgR3b3JkGAIgASgJEhAKCHR1cm5fc2VxGAMgASgNImoKCEdhbWVPdmVyEg0KBWlfd29uGAEgASgIEicKBnJlYXNvbhgCIAEoDjIXLm5vaXR1LnYxLkdhbWVFbmRSZWFzb24SEAoIbXlfc2NvcmUYAyABKA0SFAoMY2hhaW5fbGVuZ3RoGAQgASgNIjcKDE9wcG9uZW50TGVmdBIVCg1jYW5fcmVjb25uZWN0GAEgASgIEhAKCGdyYWNlX21zGAIgASgNIiwKC1NlcnZlckVycm9yEgwKBGNvZGUYASABKAkSDwoHbWVzc2FnZRgCIAEoCSI2CgRQb25nEhYKDmNsaWVudF90aW1lX21zGAEgASgDEhYKDnNlcnZlcl90aW1lX21zGAIgASgDIssDCg1TZXJ2ZXJNZXNzYWdlEiQKB3dlbGNvbWUYASABKAsyES5ub2l0dS52MS5XZWxjb21lSAASLQoMcm9vbV9jcmVhdGVkGAIgASgLMhUubm9pdHUudjEuUm9vbUNyZWF0ZWRIABIrCgtyb29tX2pvaW5lZBgDIAEoCzIULm5vaXR1LnYxLlJvb21Kb2luZWRIABItCgxnYW1lX3N0YXJ0ZWQYBCABKAsyFS5ub2l0dS52MS5HYW1lU3RhcnRlZEgAEisKC3R1cm5fdXBkYXRlGAUgASgLMhQubm9pdHUudjEuVHVyblVwZGF0ZUgAEi8KDW1vdmVfcmVqZWN0ZWQYBiABKAsyFi5ub2l0dS52MS5Nb3ZlUmVqZWN0ZWRIABInCglnYW1lX292ZXIYByABKAsyEi5ub2l0dS52MS5HYW1lT3ZlckgAEi8KDW9wcG9uZW50X2xlZnQYCCABKAsyFi5ub2l0dS52MS5PcHBvbmVudExlZnRIABImCgVlcnJvchgJIAEoCzIVLm5vaXR1LnYxLlNlcnZlckVycm9ySAASHgoEcG9uZxgKIAEoCzIOLm5vaXR1LnYxLlBvbmdIAEIJCgdwYXlsb2FkKmkKCkRpZmZpY3VsdHkSGgoWRElGRklDVUxUWV9VTlNQRUNJRklFRBAAEhMKD0RJRkZJQ1VMVFlfRUFTWRABEhUKEURJRkZJQ1VMVFlfTUVESVVNEAISEwoPRElGRklDVUxUWV9IQVJEEAMqjgIKDFJlamVjdFJlYXNvbhIdChlSRUpFQ1RfUkVBU09OX1VOU1BFQ0lGSUVEEAASIwofUkVKRUNUX1JFQVNPTl9UT09fRkVXX1NZTExBQkxFUxABEhwKGFJFSkVDVF9SRUFTT05fV1JPTkdfTElOSxACEiMKH1JFSkVDVF9SRUFTT05fTk9UX0lOX0RJQ1RJT05BUlkQAxIeChpSRUpFQ1RfUkVBU09OX0FMUkVBRFlfVVNFRBAEEh8KG1JFSkVDVF9SRUFTT05fTk9UX1lPVVJfVFVSThAFEhkKFVJFSkVDVF9SRUFTT05fVElNRU9VVBAGEhsKF1JFSkVDVF9SRUFTT05fR0FNRV9PVkVSEAcqsQEKDUdhbWVFbmRSZWFzb24SHwobR0FNRV9FTkRfUkVBU09OX1VOU1BFQ0lGSUVEEAASGwoXR0FNRV9FTkRfUkVBU09OX1RJTUVPVVQQARIhCh1HQU1FX0VORF9SRUFTT05fTk9fTEVHQUxfTU9WRRACEiEKHUdBTUVfRU5EX1JFQVNPTl9PUFBPTkVOVF9MRUZUEAMSHAoYR0FNRV9FTkRfUkVBU09OX1JFU0lHTkVEEARCOlo4Z2l0aHViLmNvbS90aWVubm05OWRldi9ub2l0dS9zZXJ2ZXIvZ2VuL25vaXR1L3YxO25vaXR1djFiBnByb3RvMw");
fileDesc("ChNub2l0dS92MS9nYW1lLnByb3RvEghub2l0dS52MSJJCgVIZWxsbxIYChBwcm90b2NvbF92ZXJzaW9uGAEgASgNEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIQCghuaWNrbmFtZRgDIAEoCSI4CgxTdGFydEJvdEdhbWUSKAoKZGlmZmljdWx0eRgBIAEoDjIULm5vaXR1LnYxLkRpZmZpY3VsdHkiDAoKQ3JlYXRlUm9vbSIdCghKb2luUm9vbRIRCglyb29tX2NvZGUYASABKAkiLAoKU3VibWl0V29yZBIMCgR3b3JkGAEgASgJEhAKCHR1cm5fc2VxGAIgASgNIggKBlJlc2lnbiIQCg5SZXF1ZXN0UmVtYXRjaCIeCgRQaW5nEhYKDmNsaWVudF90aW1lX21zGAEgASgDIuoCCg1DbGllbnRNZXNzYWdlEiAKBWhlbGxvGAEgASgLMg8ubm9pdHUudjEuSGVsbG9IABIwCg5zdGFydF9ib3RfZ2FtZRgCIAEoCzIWLm5vaXR1LnYxLlN0YXJ0Qm90R2FtZUgAEisKC2NyZWF0ZV9yb29tGAMgASgLMhQubm9pdHUudjEuQ3JlYXRlUm9vbUgAEicKCWpvaW5fcm9vbRgEIAEoCzISLm5vaXR1LnYxLkpvaW5Sb29tSAASKwoLc3VibWl0X3dvcmQYBSABKAsyFC5ub2l0dS52MS5TdWJtaXRXb3JkSAASIgoGcmVzaWduGAYgASgLMhAubm9pdHUudjEuUmVzaWduSAASHgoEcGluZxgHIAEoCzIOLm5vaXR1LnYxLlBpbmdIABIzCg9yZXF1ZXN0X3JlbWF0Y2gYCCABKAsyGC5ub2l0dS52MS5SZXF1ZXN0UmVtYXRjaEgAQgkKB3BheWxvYWQiaAoHV2VsY29tZRISCgpzZXNzaW9uX2lkGAEgASgJEhQKDHJlc3VtZV90b2tlbhgCIAEoCRIYChBwcm90b2NvbF92ZXJzaW9uGAMgASgNEhkKEWFjY2VwdGVkX25pY2tuYW1lGAQgASgJIiAKC1Jvb21DcmVhdGVkEhEKCXJvb21fY29kZRgBIAEoCSI2CgpSb29tSm9pbmVkEhEKCXJvb21fY29kZRgBIAEoCRIVCg1vcHBvbmVudF9uYW1lGAIgASgJIlsKClBsYXllZFdvcmQSDAoEd29yZBgBIAEoCRINCgVieV9tZRgCIAEoCBIOCgZwb2ludHMYAyABKA0SEQoJc3lsbGFibGVzGAQgASgNEg0KBXR5cGVkGAUgASgJIpEBCgtHYW1lU3RhcnRlZBIUCgxvcGVuaW5nX3dvcmQYASABKAkSGAoQY3VycmVudF9zeWxsYWJsZRgCIAEoCRIPCgdteV90dXJuGAMgASgIEhgKEGRlYWRsaW5lX3VuaXhfbXMYBCABKAMSEAoIdHVybl9zZXEYBSABKA0SFQoNdHVybl9saW1pdF9tcxgGIAEoDSLJAQoKVHVyblVwZGF0ZRIkCgZwbGF5ZWQYASABKAsyFC5ub2l0dS52MS5QbGF5ZWRXb3JkEhgKEGN1cnJlbnRfc3lsbGFibGUYAiABKAkSDwoHbXlfdHVybhgDIAEoCBIYChBkZWFkbGluZV91bml4X21zGAQgASgDEhAKCHR1cm5fc2VxGAUgASgNEhAKCG15X3Njb3JlGAYgASgNEhYKDm9wcG9uZW50X3Njb3JlGAcgASgNEhQKDGNoYWluX2xlbmd0aBgIIAEoDSJWCgxNb3ZlUmVqZWN0ZWQSJgoGcmVhc29uGAEgASgOMhYubm9pdHUudjEuUmVqZWN0UmVhc29uEgwKBHdvcmQYAiABKAkSEAoIdHVybl9zZXEYAyABKA0iagoIR2FtZU92ZXISDQoFaV93b24YASABKAgSJwoGcmVhc29uGAIgASgOMhcubm9pdHUudjEuR2FtZUVuZFJlYXNvbhIQCghteV9zY29yZRgDIAEoDRIUCgxjaGFpbl9sZW5ndGgYBCABKA0iNwoMT3Bwb25lbnRMZWZ0EhUKDWNhbl9yZWNvbm5lY3QYASABKAgSEAoIZ3JhY2VfbXMYAiABKA0iLAoLU2VydmVyRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJIjYKBFBvbmcSFgoOY2xpZW50X3RpbWVfbXMYASABKAMSFgoOc2VydmVyX3RpbWVfbXMYAiABKAMiVAoMUmVtYXRjaFN0YXRlEhIKCmlfYWNjZXB0ZWQYASABKAgSGQoRb3Bwb25lbnRfYWNjZXB0ZWQYAiABKAgSFQoNZXhwaXJlc19pbl9tcxgDIAEoDSL8AwoNU2VydmVyTWVzc2FnZRIkCgd3ZWxjb21lGAEgASgLMhEubm9pdHUudjEuV2VsY29tZUgAEi0KDHJvb21fY3JlYXRlZBgCIAEoCzIVLm5vaXR1LnYxLlJvb21DcmVhdGVkSAASKwoLcm9vbV9qb2luZWQYAyABKAsyFC5ub2l0dS52MS5Sb29tSm9pbmVkSAASLQoMZ2FtZV9zdGFydGVkGAQgASgLMhUubm9pdHUudjEuR2FtZVN0YXJ0ZWRIABIrCgt0dXJuX3VwZGF0ZRgFIAEoCzIULm5vaXR1LnYxLlR1cm5VcGRhdGVIABIvCg1tb3ZlX3JlamVjdGVkGAYgASgLMhYubm9pdHUudjEuTW92ZVJlamVjdGVkSAASJwoJZ2FtZV9vdmVyGAcgASgLMhIubm9pdHUudjEuR2FtZU92ZXJIABIvCg1vcHBvbmVudF9sZWZ0GAggASgLMhYubm9pdHUudjEuT3Bwb25lbnRMZWZ0SAASJgoFZXJyb3IYCSABKAsyFS5ub2l0dS52MS5TZXJ2ZXJFcnJvckgAEh4KBHBvbmcYCiABKAsyDi5ub2l0dS52MS5Qb25nSAASLwoNcmVtYXRjaF9zdGF0ZRgLIAEoCzIWLm5vaXR1LnYxLlJlbWF0Y2hTdGF0ZUgAQgkKB3BheWxvYWQqaQoKRGlmZmljdWx0eRIaChZESUZGSUNVTFRZX1VOU1BFQ0lGSUVEEAASEwoPRElGRklDVUxUWV9FQVNZEAESFQoRRElGRklDVUxUWV9NRURJVU0QAhITCg9ESUZGSUNVTFRZX0hBUkQQAyqOAgoMUmVqZWN0UmVhc29uEh0KGVJFSkVDVF9SRUFTT05fVU5TUEVDSUZJRUQQABIjCh9SRUpFQ1RfUkVBU09OX1RPT19GRVdfU1lMTEFCTEVTEAESHAoYUkVKRUNUX1JFQVNPTl9XUk9OR19MSU5LEAISIwofUkVKRUNUX1JFQVNPTl9OT1RfSU5fRElDVElPTkFSWRADEh4KGlJFSkVDVF9SRUFTT05fQUxSRUFEWV9VU0VEEAQSHwobUkVKRUNUX1JFQVNPTl9OT1RfWU9VUl9UVVJOEAUSGQoVUkVKRUNUX1JFQVNPTl9USU1FT1VUEAYSGwoXUkVKRUNUX1JFQVNPTl9HQU1FX09WRVIQByqxAQoNR2FtZUVuZFJlYXNvbhIfChtHQU1FX0VORF9SRUFTT05fVU5TUEVDSUZJRUQQABIbChdHQU1FX0VORF9SRUFTT05fVElNRU9VVBABEiEKHUdBTUVfRU5EX1JFQVNPTl9OT19MRUdBTF9NT1ZFEAISIQodR0FNRV9FTkRfUkVBU09OX09QUE9ORU5UX0xFRlQQAxIcChhHQU1FX0VORF9SRUFTT05fUkVTSUdORUQQBEI6WjhnaXRodWIuY29tL3RpZW5ubTk5ZGV2L25vaXR1L3NlcnZlci9nZW4vbm9pdHUvdjE7bm9pdHV2MWIGcHJvdG8z");
/**
* Describes the message noitu.v1.Hello.
@@ -65,103 +65,117 @@ export const SubmitWordSchema = /*@__PURE__*/
export const ResignSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 5);
/**
* Describes the message noitu.v1.RequestRematch.
* Use `create(RequestRematchSchema)` to create a new message.
*/
export const RequestRematchSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 6);
/**
* Describes the message noitu.v1.Ping.
* Use `create(PingSchema)` to create a new message.
*/
export const PingSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 6);
messageDesc(file_noitu_v1_game, 7);
/**
* Describes the message noitu.v1.ClientMessage.
* Use `create(ClientMessageSchema)` to create a new message.
*/
export const ClientMessageSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 7);
messageDesc(file_noitu_v1_game, 8);
/**
* Describes the message noitu.v1.Welcome.
* Use `create(WelcomeSchema)` to create a new message.
*/
export const WelcomeSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 8);
messageDesc(file_noitu_v1_game, 9);
/**
* Describes the message noitu.v1.RoomCreated.
* Use `create(RoomCreatedSchema)` to create a new message.
*/
export const RoomCreatedSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 9);
messageDesc(file_noitu_v1_game, 10);
/**
* Describes the message noitu.v1.RoomJoined.
* Use `create(RoomJoinedSchema)` to create a new message.
*/
export const RoomJoinedSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 10);
messageDesc(file_noitu_v1_game, 11);
/**
* Describes the message noitu.v1.PlayedWord.
* Use `create(PlayedWordSchema)` to create a new message.
*/
export const PlayedWordSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 11);
messageDesc(file_noitu_v1_game, 12);
/**
* Describes the message noitu.v1.GameStarted.
* Use `create(GameStartedSchema)` to create a new message.
*/
export const GameStartedSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 12);
messageDesc(file_noitu_v1_game, 13);
/**
* Describes the message noitu.v1.TurnUpdate.
* Use `create(TurnUpdateSchema)` to create a new message.
*/
export const TurnUpdateSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 13);
messageDesc(file_noitu_v1_game, 14);
/**
* Describes the message noitu.v1.MoveRejected.
* Use `create(MoveRejectedSchema)` to create a new message.
*/
export const MoveRejectedSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 14);
messageDesc(file_noitu_v1_game, 15);
/**
* Describes the message noitu.v1.GameOver.
* Use `create(GameOverSchema)` to create a new message.
*/
export const GameOverSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 15);
messageDesc(file_noitu_v1_game, 16);
/**
* Describes the message noitu.v1.OpponentLeft.
* Use `create(OpponentLeftSchema)` to create a new message.
*/
export const OpponentLeftSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 16);
messageDesc(file_noitu_v1_game, 17);
/**
* Describes the message noitu.v1.ServerError.
* Use `create(ServerErrorSchema)` to create a new message.
*/
export const ServerErrorSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 17);
messageDesc(file_noitu_v1_game, 18);
/**
* Describes the message noitu.v1.Pong.
* Use `create(PongSchema)` to create a new message.
*/
export const PongSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 18);
messageDesc(file_noitu_v1_game, 19);
/**
* Describes the message noitu.v1.RematchState.
* Use `create(RematchStateSchema)` to create a new message.
*/
export const RematchStateSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 20);
/**
* Describes the message noitu.v1.ServerMessage.
* Use `create(ServerMessageSchema)` to create a new message.
*/
export const ServerMessageSchema = /*@__PURE__*/
messageDesc(file_noitu_v1_game, 19);
messageDesc(file_noitu_v1_game, 21);
/**
* Describes the enum noitu.v1.Difficulty.
+54
View File
@@ -0,0 +1,54 @@
/**
* Room codes as the client has to handle them: typed by hand, or pasted from a
* chat message.
*
* The alphabet mirrors the server's. It omits 0/O and 1/I/L because those are
* the pairs people get wrong when reading a code out. A test checks this
* against the Go constant rather than trusting the copy.
*/
export const ROOM_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';
export const ROOM_CODE_LENGTH = 6;
/** Pulls the code out of a pasted invite link. */
const LINK_CODE = /[?&]code=([^&#\s]+)/i;
/** Separators people put inside or around a code when writing it down. */
const SEPARATORS = /[\s._-]+/g;
/**
* Turns whatever arrived into what the server would recognise.
*
* Two shapes are worth handling, because they are the two ways a code reaches
* a player: the code itself, in any case and spaced or hyphenated for reading,
* and a whole invite link pasted from a message.
*
* Only separators are removed. Dropping every character outside the alphabet
* instead would turn any sentence into six plausible letters, and the player
* would be told the room does not exist rather than that they pasted the wrong
* thing.
*
* @param {string} raw
* @returns {string}
*/
export function normalizeRoomCode(raw) {
const text = String(raw ?? '').trim();
const link = text.match(LINK_CODE);
const source = link ? link[1] : text;
return source.replace(SEPARATORS, '').toUpperCase();
}
/**
* Whether a normalized code is worth sending. Checking here saves a round trip
* and, more usefully, tells the player which of the two failures they have:
* a malformed code, or a room that is not there.
*
* @param {string} code
* @returns {boolean}
*/
export function isRoomCode(code) {
return (
code.length === ROOM_CODE_LENGTH && [...code].every((ch) => ROOM_CODE_ALPHABET.includes(ch))
);
}
+37 -1
View File
@@ -18,7 +18,12 @@ import { rejectMessage, errorMessage } from '$lib/i18n/vi.js';
/** @returns {any} */
function initialState() {
return {
/** @type {'idle' | 'playing' | 'over'} */
/**
* Where the screen is. `waiting` is online-only: the room exists and
* its code can be shared, but there is nobody to play yet.
*
* @type {'idle' | 'waiting' | 'playing' | 'over'}
*/
phase: 'idle',
/** @type {ChainEntry[]} */
chain: [],
@@ -41,6 +46,14 @@ function initialState() {
result: null,
/** @type {{ canReconnect: boolean, graceMs: number } | null} */
opponentLeft: null,
/**
* The offer that follows a finished online game, or null when none is
* open. Both sides of the answer come from the server, so neither
* client has to work out which acceptance is whose.
*
* @type {{ iAccepted: boolean, opponentAccepted: boolean, expiresInMs: number } | null}
*/
rematch: null,
/** @type {string | null} */
error: null
};
@@ -81,14 +94,22 @@ export function createGameStore() {
case 'roomCreated':
state.roomCode = value.roomCode;
// The room exists but has one seat filled. A resumed session
// that was waiting lands here too, which is why the phase is
// set rather than assumed.
if (state.phase !== 'playing') state.phase = 'waiting';
break;
case 'roomJoined':
state.roomCode = value.roomCode;
state.opponentName = value.opponentName;
// The opponent is in the room, which is also how a reconnect is
// announced. Either way there is nobody to be waiting for.
state.opponentLeft = null;
break;
case 'gameStarted':
// reset() already clears the rematch offer that led here.
reset();
state.phase = 'playing';
state.chain = [
@@ -156,6 +177,18 @@ export function createGameStore() {
canReconnect: value.canReconnect,
graceMs: value.graceMs
};
// An opponent who cannot come back also ends any rematch offer.
// Leaving the prompt up would show a countdown with nobody left
// to answer it.
if (!value.canReconnect) state.rematch = null;
break;
case 'rematchState':
state.rematch = {
iAccepted: value.iAccepted,
opponentAccepted: value.opponentAccepted,
expiresInMs: value.expiresInMs
};
break;
case 'error':
@@ -180,6 +213,9 @@ export function createGameStore() {
},
clearOpponentLeft() {
state.opponentLeft = null;
},
clearRematch() {
state.rematch = null;
}
};
}
+48
View File
@@ -20,6 +20,17 @@ export const BACKOFF_MS = [500, 1000, 2000, 4000, 8000];
/** How often the clock-offset probe is sent while the socket is open. */
export const PING_INTERVAL_MS = 5000;
/**
* How long the client waits for any frame before deciding the socket is dead.
*
* A connection that fails without closing — a phone leaving coverage, a laptop
* suspending, a network dropping every packet — leaves `onclose` unfired, so
* the page would keep showing a live connection and a running countdown over a
* socket nothing can reach. The server's own ping catches it eventually; this
* catches it inside one turn, which is when the player is looking.
*/
export const LIVENESS_TIMEOUT_MS = 3 * PING_INTERVAL_MS;
/**
* Server errors that reconnecting cannot fix.
*
@@ -64,6 +75,23 @@ function safeSessionStorage() {
}
}
/**
* Whether this tab holds a session the server might still be able to restore.
*
* A screen that would otherwise wait for the player to ask for something uses
* this to reconnect straight away, so refreshing the page mid-game returns to
* the game rather than to the lobby.
*
* @returns {boolean}
*/
export function hasStoredSession() {
try {
return !!safeSessionStorage()?.getItem(RESUME_KEY);
} catch {
return false;
}
}
/**
* Creates the socket client.
*
@@ -105,6 +133,9 @@ export function createClient({
let pingTimer = null;
let clockOffsetMs = 0;
let status = Status.CLOSED;
// When a frame last arrived. Any frame counts: the point is whether the
// socket still carries traffic, not which message proved it.
let lastFrameAt = 0;
/** @param {string} next */
function setStatus(next) {
@@ -140,7 +171,22 @@ export function createClient({
function schedulePing() {
stopPing();
const due = now() + PING_INTERVAL_MS;
pingTimer = schedule(() => {
// A backgrounded tab has its timers throttled, so this can fire
// minutes late through no fault of the connection. Judging silence
// on a tick that was itself late would close a healthy socket every
// time the player switched tabs; the next on-time tick decides.
const onTime = now() - due <= PING_INTERVAL_MS;
if (onTime && now() - lastFrameAt > LIVENESS_TIMEOUT_MS) {
// Closing it ourselves is what turns a silently dead socket into
// an onclose, and therefore into a reconnect.
stopPing();
socket?.close();
return;
}
send(ping(now()));
schedulePing();
}, PING_INTERVAL_MS);
@@ -174,6 +220,7 @@ export function createClient({
socket = ws;
ws.onopen = () => {
lastFrameAt = now();
// Hello goes out before the status is announced. The server refuses
// every other message until the handshake lands, and a listener
// reacting to "open" by sending something would otherwise race it.
@@ -198,6 +245,7 @@ export function createClient({
// version check instead.
return;
}
lastFrameAt = now();
intercept(msg);
onMessage(msg);
};
+12 -2
View File
@@ -1,4 +1,4 @@
import { Status, createClient } from './client.js';
import { Status, createClient, hasStoredSession } from './client.js';
import { game } from '$lib/stores/game.svelte.js';
import { settings } from '$lib/stores/settings.svelte.js';
@@ -50,6 +50,16 @@ export function serverNow() {
return client?.serverNow() ?? Date.now();
}
/**
* Drops the resume token without touching the connection.
*
* Used when a resume was refused: the token is spent, but the socket is fine
* and the player may well want to start something new on it.
*/
export function forgetSession() {
client?.forgetSession();
}
/**
* Closes the socket and forgets the session.
*
@@ -66,4 +76,4 @@ export function disconnect() {
}
export const connection = state;
export { Status };
export { Status, hasStoredSession };
+11
View File
@@ -5,6 +5,7 @@ import {
HelloSchema,
JoinRoomSchema,
PingSchema,
RequestRematchSchema,
ResignSchema,
StartBotGameSchema,
SubmitWordSchema
@@ -74,6 +75,16 @@ export function submitWord(word, turnSeq) {
});
}
/**
* Asks to play the same room again. There is no matching decline: leaving is
* the decline, and the server learns about that from the socket closing.
*/
export function requestRematch() {
return create(ClientMessageSchema, {
payload: { case: 'requestRematch', value: create(RequestRematchSchema, {}) }
});
}
export function resign() {
return create(ClientMessageSchema, {
payload: { case: 'resign', value: create(ResignSchema, {}) }