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

+46
View File
@@ -12,6 +12,7 @@
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.14.1",
"@playwright/test": "^1.63.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
@@ -775,6 +776,22 @@
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2068,6 +2085,35 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.28",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
+3 -1
View File
@@ -9,13 +9,15 @@
"preview": "vite preview",
"prepare": "svelte-kit sync",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"test": "vite build && vitest run"
"test": "vite build && vitest run",
"test:e2e": "vite build && playwright test"
},
"dependencies": {
"@bufbuild/protobuf": "^2.14.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.14.1",
"@playwright/test": "^1.63.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
+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, {}) }
+274 -15
View File
@@ -1,23 +1,218 @@
<script>
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import GameBoard from '$lib/components/GameBoard.svelte';
import GameOverPanel from '$lib/components/GameOverPanel.svelte';
import NicknameInput from '$lib/components/NicknameInput.svelte';
import OpponentStatus from '$lib/components/OpponentStatus.svelte';
import RematchPrompt from '$lib/components/RematchPrompt.svelte';
import WaitingRoom from '$lib/components/WaitingRoom.svelte';
import { t } from '$lib/i18n/vi.js';
import { isRoomCode, normalizeRoomCode, ROOM_CODE_LENGTH } from '$lib/room-code.js';
import { game } from '$lib/stores/game.svelte.js';
import { createRoom, joinRoom, requestRematch, resign, submitWord } from '$lib/ws/messages.js';
import {
Status,
connect,
connection,
disconnect,
forgetSession,
hasStoredSession,
send
} from '$lib/ws/connection.svelte.js';
/**
* 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}
*/
let pending = $state(null);
let codeInput = $state(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
let codeError = $state('');
// True while the only reason this screen has a socket is to reclaim a game
// it might no longer be able to reclaim.
let resuming = $state(false);
const inviteCode = $derived(normalizeRoomCode(page.url.searchParams.get('code') ?? ''));
const playing = $derived(game.state.phase === 'playing' || game.state.phase === 'over');
// The resume worked, so nothing that happens from here is its fault.
$effect(() => {
if (playing || game.state.phase === 'waiting') untrack(() => (resuming = false));
});
// Owns the socket while this screen is on, exactly as the bot screen does.
// An invite link is a request to join, so it is honoured on arrival rather
// than making the player press a button they did not ask for.
$effect(() => {
const code = inviteCode;
untrack(() => {
game.reset();
// Deliberately not connecting yet. The nickname is typed on this
// screen, and Hello carries it once — a socket opened on arrival
// would introduce the player under whatever name was stored before
// they got here.
if (isRoomCode(code)) {
request({ kind: 'join', code });
} else if (hasStoredSession()) {
// This tab was already in a game. Reconnecting restores it, which
// is what a player who refreshed mid-game is expecting; the
// nickname is already settled, so there is nothing to wait for.
resuming = true;
connect();
}
});
return () => {
if (game.state.phase === 'playing') send(resign());
pending = null;
disconnect();
game.reset();
};
});
// Held requests go out once the handshake has landed.
$effect(() => {
const open = connection.status === Status.OPEN;
untrack(() => flush(open));
});
// A resume that the server cannot honour is not something the player did.
// Reporting it would open the lobby with a red banner about a game they
// have already left behind, so the token is dropped quietly instead.
$effect(() => {
const failed = resuming && !!game.state.error;
untrack(() => {
if (!failed) return;
resuming = false;
game.clearError();
forgetSession();
});
});
/** @param {{ kind: 'create' } | { kind: 'join', code: string }} req */
function request(req) {
codeError = '';
resuming = false;
game.clearError();
pending = req;
// The handshake carries the nickname as it stands now, which is why the
// connection waits until the player has actually asked for a room.
connect();
flush(connection.status === Status.OPEN);
}
/** @param {boolean} isOpen */
function flush(isOpen) {
if (!pending || !isOpen) 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));
if (sent) pending = null;
}
function join() {
const code = normalizeRoomCode(codeInput);
if (!isRoomCode(code)) {
codeError = t.roomCodeInvalid;
return;
}
request({ kind: 'join', code });
}
function leave() {
goto('/');
}
/**
* @param {string} word
* @returns {boolean}
*/
function play(word) {
return send(submitWord(word, game.state.turnSeq));
}
function giveUp() {
if (confirm(t.resignConfirm)) send(resign());
}
</script>
<!--
The online 1v1 flows land in phase 7. This route exists now so the home
screen has somewhere real to point and the deep link is already covered by
the server's single-page fallback.
-->
<section class="online">
<h1>{t.onlineTitle}</h1>
<p>{t.onlineComingSoon}</p>
<a href="/">{t.back}</a>
{#if playing}
<GameBoard
opponentLabel={game.state.opponentName || t.opponent}
modeLabel={game.state.roomCode}
onsubmit={play}
onresign={giveUp}
>
{#snippet banner()}
<OpponentStatus />
{/snippet}
{#snippet gameOver()}
<!-- No rematch button on the panel: two people have to agree, and
an offer is not always open. The prompt below appears only
while one is, so it is the only thing that can ask. -->
<GameOverPanel isRecord={false} onhome={leave} />
<RematchPrompt onaccept={() => send(requestRematch())} />
{/snippet}
</GameBoard>
{:else if game.state.phase === 'waiting'}
<WaitingRoom code={game.state.roomCode} onleave={leave} />
{:else}
<h1>{t.onlineTitle}</h1>
<p class="intro">{t.onlineIntro}</p>
<NicknameInput />
{#if game.state.error}
<p class="error" role="alert" data-testid="join-error">{game.state.error}</p>
{/if}
<button type="button" class="primary" onclick={() => request({ kind: 'create' })}>
{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">{t.joinRoom}</button>
</div>
<p class="hint" class:invalid={codeError}>{codeError || t.roomCodeHint}</p>
</form>
<a class="back" href="/">{t.back}</a>
{/if}
</section>
<style>
.online {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
gap: 16px;
min-height: 0;
padding-top: 12px;
}
@@ -26,19 +221,83 @@
font-size: 1.3rem;
}
p {
.intro {
margin: 0;
color: var(--text-muted);
}
a {
align-self: flex-start;
padding: 10px 16px;
.primary {
padding: 14px;
border: 0;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
.join {
display: flex;
flex-direction: column;
gap: 6px;
}
label {
font-weight: 600;
font-size: 0.9rem;
}
.row {
display: flex;
gap: 8px;
}
input {
flex: 1;
min-width: 0;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: inherit;
font-size: 1rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.row button {
padding: 12px 18px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface-alt);
font-weight: 600;
text-decoration: none;
}
.hint {
margin: 0;
color: var(--text-muted);
font-size: 0.8rem;
}
.hint.invalid {
color: var(--danger);
}
.error {
margin: 0;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: var(--danger-soft);
color: var(--danger);
font-size: 0.9rem;
}
.back {
align-self: flex-start;
color: var(--text-muted);
font-size: 0.9rem;
}
</style>
+12 -127
View File
@@ -2,12 +2,8 @@
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ChainHistory from '$lib/components/ChainHistory.svelte';
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
import CountdownRing from '$lib/components/CountdownRing.svelte';
import GameBoard from '$lib/components/GameBoard.svelte';
import GameOverPanel from '$lib/components/GameOverPanel.svelte';
import ScoreBoard from '$lib/components/ScoreBoard.svelte';
import WordInput from '$lib/components/WordInput.svelte';
import { difficultyLabels, t } from '$lib/i18n/vi.js';
import { Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
import { createBotSession } from '$lib/stores/bot-session.svelte.js';
@@ -95,126 +91,15 @@
}
</script>
<section class="play">
<div class="top">
<ConnectionBadge />
<span class="mode">{difficultyLabels[difficulty]}</span>
</div>
<ScoreBoard opponentLabel={t.opponent} />
{#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'}
<GameBoard
opponentLabel={t.opponent}
modeLabel={difficultyLabels[difficulty]}
onsubmit={play}
onresign={giveUp}
>
{#snippet gameOver()}
<!-- A bot always plays again, so there is nothing to negotiate: the
button starts the next game rather than offering one. -->
<GameOverPanel {isRecord} onrematch={rematch} onhome={goHome} />
{:else}
<div class="turn">
<CountdownRing />
<div class="prompt">
<p class="who">{game.state.myTurn ? t.yourTurn : t.opponentTurn}</p>
<p class="syllable">
<span class="label">{t.currentSyllable}</span>
<strong>{game.state.currentSyllable || '…'}</strong>
</p>
</div>
</div>
<WordInput onsubmit={play} />
{/if}
<ChainHistory />
{#if game.state.phase === 'playing'}
<button type="button" class="resign" onclick={giveUp}>{t.resign}</button>
{/if}
</section>
<style>
.play {
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>
{/snippet}
</GameBoard>
+46
View File
@@ -0,0 +1,46 @@
// The upstream dictionary is pinned in two places: the Makefile, which builds
// it for a developer, and the Dockerfile, which builds it for the image. They
// have to agree, or the container ships a wordlist nobody tested against.
//
// This lives in the JavaScript suite for no better reason than that it is the
// suite that already reads other files in the repository. It is checking two
// build files, not the frontend.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const makefile = readFileSync(fileURLToPath(new URL('../../Makefile', import.meta.url)), 'utf8');
const dockerfile = readFileSync(fileURLToPath(new URL('../../Dockerfile', import.meta.url)), 'utf8');
/**
* @param {string} source
* @param {RegExp} pattern
* @param {string} what
*/
function pin(source, pattern, what) {
const match = source.match(pattern);
expect(match, `${what} not found`).not.toBeNull();
return match?.[1].trim();
}
describe('the pinned upstream dictionary', () => {
const makeUrl = pin(makefile, /DICT_URL\s*:?=\s*(\S+)/, 'DICT_URL in the Makefile');
const makeSha = pin(makefile, /DICT_SHA256\s*:?=\s*(\S+)/, 'DICT_SHA256 in the Makefile');
const dockerUrl = pin(dockerfile, /ARG DICT_URL=(\S+)/, 'DICT_URL in the Dockerfile');
const dockerSha = pin(dockerfile, /ARG DICT_SHA256=(\S+)/, 'DICT_SHA256 in the Dockerfile');
it('is the same release in both build files', () => {
expect(dockerUrl).toBe(makeUrl);
});
it('is pinned to the same checksum in both build files', () => {
expect(dockerSha).toBe(makeSha);
});
it('is a checksum, not a placeholder', () => {
// A guard that only compared the two would pass happily if both were
// blanked, which is the one way this pin can silently stop pinning.
expect(makeSha).toMatch(/^[0-9a-f]{64}$/);
});
});
+68
View File
@@ -227,3 +227,71 @@ describe('pong', () => {
expect(JSON.stringify(store.state)).toBe(before);
});
});
describe('online rooms', () => {
it('waits once the room exists but has one seat filled', () => {
const store = createGameStore();
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
expect(store.state.phase).toBe('waiting');
expect(store.state.roomCode).toBe('K7M2QP');
});
it('does not drop a live game back into waiting', () => {
// A resumed session is told its room again. Treating that as "waiting"
// would replace the board with the invite screen mid-game.
const store = createGameStore();
store.apply(started());
store.apply(msg('roomCreated', { roomCode: 'K7M2QP' }));
expect(store.state.phase).toBe('playing');
});
});
describe('rematch offers', () => {
function finished(store) {
store.apply(started());
store.apply(msg('gameOver', { iWon: false, reason: GameEndReason.RESIGNED }));
}
it('records both sides of the answer as the server rendered them', () => {
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
expect(store.state.rematch).toEqual({
iAccepted: true,
opponentAccepted: false,
expiresInMs: 27500
});
});
it('ends the offer when the opponent cannot come back', () => {
// The countdown would otherwise keep running with nobody to answer it.
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: false, expiresInMs: 27500 }));
store.apply(msg('opponentLeft', { canReconnect: false, graceMs: 0 }));
expect(store.state.rematch).toBeNull();
});
it('keeps the offer while the opponent is only disconnected', () => {
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: false, opponentAccepted: true, expiresInMs: 20000 }));
store.apply(msg('opponentLeft', { canReconnect: true, graceMs: 30000 }));
expect(store.state.rematch).not.toBeNull();
});
it('clears the offer once the next game starts', () => {
const store = createGameStore();
finished(store);
store.apply(msg('rematchState', { iAccepted: true, opponentAccepted: true, expiresInMs: 15000 }));
store.apply(started({ openingWord: 'thí sinh' }));
expect(store.state.rematch).toBeNull();
expect(store.state.phase).toBe('playing');
});
});
+95
View File
@@ -0,0 +1,95 @@
// Room codes cross a gap the type system cannot see: the server generates them
// from a Go constant and the client validates them against a JavaScript copy.
// The first test here reads the Go source so the copy cannot quietly drift.
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
ROOM_CODE_ALPHABET,
ROOM_CODE_LENGTH,
isRoomCode,
normalizeRoomCode
} from '../src/lib/room-code.js';
const hubSource = fileURLToPath(new URL('../../server/internal/wsapi/hub.go', import.meta.url));
describe('the alphabet', () => {
const go = readFileSync(hubSource, 'utf8');
it('matches the one the server generates codes from', () => {
const match = go.match(/roomCodeAlphabet\s*=\s*"([^"]+)"/);
expect(match, 'roomCodeAlphabet not found in hub.go').not.toBeNull();
expect(ROOM_CODE_ALPHABET).toBe(match?.[1]);
});
it('matches the length the server generates', () => {
const match = go.match(/roomCodeLen\s*=\s*(\d+)/);
expect(match, 'roomCodeLen not found in hub.go').not.toBeNull();
expect(ROOM_CODE_LENGTH).toBe(Number(match?.[1]));
});
it('omits the characters people confuse when reading a code aloud', () => {
for (const ch of ['0', 'O', '1', 'I', 'L']) {
expect(ROOM_CODE_ALPHABET.includes(ch), `${ch} should not be in the alphabet`).toBe(false);
}
});
});
describe('normalizeRoomCode', () => {
it('upper-cases what was typed in lower case', () => {
expect(normalizeRoomCode('k7m2qp')).toBe('K7M2QP');
});
it('accepts a code with punctuation or spacing around it', () => {
expect(normalizeRoomCode(' K7M2-QP ')).toBe('K7M2QP');
});
it('takes the code out of a pasted invite link', () => {
expect(normalizeRoomCode('https://noitu.example/online?code=K7M2QP')).toBe('K7M2QP');
});
it('reads the code from a link that carries other parameters too', () => {
expect(normalizeRoomCode('https://noitu.example/online?ref=chat&code=K7M2QP#top')).toBe(
'K7M2QP'
);
});
it('does not try to find a code buried in a sentence', () => {
// Guessing would occasionally produce a plausible code for the wrong
// room, which is worse than asking the player to paste the code itself.
expect(isRoomCode(normalizeRoomCode('vào phòng của tôi nhé'))).toBe(false);
});
it('keeps characters outside the alphabet so the code can be refused', () => {
// A code misread as O for 0 should be reported as malformed, not
// quietly rewritten into a different six-character code.
expect(normalizeRoomCode('K0MOQP')).toBe('K0MOQP');
expect(isRoomCode(normalizeRoomCode('K0MOQP'))).toBe(false);
});
it('does not truncate a long paste into a different code', () => {
expect(isRoomCode(normalizeRoomCode('K7M2QPZZZZ'))).toBe(false);
});
it('handles nothing at all', () => {
expect(normalizeRoomCode('')).toBe('');
expect(normalizeRoomCode(/** @type {any} */ (undefined))).toBe('');
});
});
describe('isRoomCode', () => {
it('accepts a well-formed code', () => {
expect(isRoomCode('K7M2QP')).toBe(true);
});
it('rejects the wrong length', () => {
expect(isRoomCode('K7M2Q')).toBe(false);
expect(isRoomCode('K7M2QPZ')).toBe(false);
});
it('rejects characters outside the alphabet', () => {
expect(isRoomCode('K7M2Q0')).toBe(false);
expect(isRoomCode('k7m2qp')).toBe(false);
});
});
+67
View File
@@ -13,6 +13,7 @@ import {
} from '../src/lib/proto/noitu/v1/game_pb.js';
import {
BACKOFF_MS,
LIVENESS_TIMEOUT_MS,
PING_INTERVAL_MS,
Status,
createClient,
@@ -460,3 +461,69 @@ describe('backoff and the handshake', () => {
expect(h.reconnectDelays().at(-1)).toBe(500);
});
});
describe('a socket that dies without closing', () => {
/**
* Fires the ping timer `ticks` times, advancing the clock by one interval
* each time — a healthy tab whose timers are running on schedule.
*
* @param {any} h
* @param {number} ticks
* @param {number} start
*/
function tick(h, ticks, start) {
let clock = start;
for (let i = 0; i < ticks; i += 1) {
clock += PING_INTERVAL_MS;
h.setClock(clock);
h.timers.flush();
}
return clock;
}
it('is closed by the client once frames stop arriving', () => {
// A connection that fails without closing leaves onclose unfired, so the
// page would keep showing a live connection and a running countdown over
// a socket nothing can reach.
const h = setup();
h.client.connect();
h.last().open();
const socket = h.last();
tick(h, LIVENESS_TIMEOUT_MS / PING_INTERVAL_MS + 1, 1000);
expect(socket.closed).toBe(true);
});
it('leaves a socket alone while frames are still arriving', () => {
const h = setup();
h.client.connect();
h.last().open();
const socket = h.last();
let clock = 1000;
for (let i = 0; i < 10; i += 1) {
clock += PING_INTERVAL_MS;
h.setClock(clock);
socket.deliver(serverMsg('pong', { clientTimeMs: 1n, serverTimeMs: 2n }));
h.timers.flush();
}
expect(socket.closed).toBe(false);
});
it('does not punish a tab whose timers were throttled', () => {
// A backgrounded tab fires its timers 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 away.
const h = setup();
h.client.connect();
h.last().open();
const socket = h.last();
h.setClock(1000 + 10 * 60_000);
h.timers.flush();
expect(socket.closed).toBe(false);
});
});
+2
View File
@@ -19,7 +19,9 @@ export default defineConfig({
// The default node environment: the cross-language fixture test reads
// files through import.meta.url, which jsdom rewrites to an http URL.
// The two suites that need a DOM opt in with a per-file docblock.
// e2e/ is Playwright's; Vitest must not try to run browser specs.
include: ['tests/**/*.test.js'],
exclude: ['e2e/**', 'node_modules/**', 'build/**'],
// SvelteKit resolves $lib during a build, not under Vitest, so the test
// run needs the same mapping stated explicitly. It points at the same
// directory the framework uses, so there is one meaning of $lib.