From ffa13a0f6e25b56a3f180a61beaf4d2f9a08076a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 5 Sep 2026 14:18:03 +0700 Subject: [PATCH] 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. --- web/package-lock.json | 46 +++ web/package.json | 4 +- web/src/lib/components/GameBoard.svelte | 153 ++++++++++ web/src/lib/components/GameOverPanel.svelte | 12 +- web/src/lib/components/OpponentStatus.svelte | 63 ++++ web/src/lib/components/RematchPrompt.svelte | 87 ++++++ web/src/lib/components/RoomCodePanel.svelte | 103 +++++++ web/src/lib/components/WaitingRoom.svelte | 59 ++++ web/src/lib/i18n/vi.js | 27 +- web/src/lib/proto/noitu/v1/game_pb.d.ts | 64 ++++ web/src/lib/proto/noitu/v1/game_pb.js | 44 ++- web/src/lib/room-code.js | 54 ++++ web/src/lib/stores/game.svelte.js | 38 ++- web/src/lib/ws/client.js | 48 +++ web/src/lib/ws/connection.svelte.js | 14 +- web/src/lib/ws/messages.js | 11 + web/src/routes/online/+page.svelte | 289 ++++++++++++++++++- web/src/routes/play/+page.svelte | 139 +-------- web/tests/dictionary-source.test.js | 46 +++ web/tests/game-store.test.js | 68 +++++ web/tests/room-code.test.js | 95 ++++++ web/tests/ws-client.test.js | 67 +++++ web/vite.config.js | 2 + 23 files changed, 1369 insertions(+), 164 deletions(-) create mode 100644 web/src/lib/components/GameBoard.svelte create mode 100644 web/src/lib/components/OpponentStatus.svelte create mode 100644 web/src/lib/components/RematchPrompt.svelte create mode 100644 web/src/lib/components/RoomCodePanel.svelte create mode 100644 web/src/lib/components/WaitingRoom.svelte create mode 100644 web/src/lib/room-code.js create mode 100644 web/tests/dictionary-source.test.js create mode 100644 web/tests/room-code.test.js diff --git a/web/package-lock.json b/web/package-lock.json index 072d684..f0801e4 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 55b4b8c..fd7554d 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/src/lib/components/GameBoard.svelte b/web/src/lib/components/GameBoard.svelte new file mode 100644 index 0000000..6f6f982 --- /dev/null +++ b/web/src/lib/components/GameBoard.svelte @@ -0,0 +1,153 @@ + + +
+
+ + {#if modeLabel}{modeLabel}{/if} +
+ + + + {#if banner}{@render banner()}{/if} + + {#if game.state.error} + + {/if} + + {#if game.state.phase === 'over'} + {@render gameOver()} + {:else} +
+ +
+

+ {game.state.myTurn ? t.yourTurn : t.opponentTurn} +

+

+ {t.currentSyllable} + {game.state.currentSyllable || '…'} +

+
+
+ + + {/if} + + + + {#if game.state.phase === 'playing'} + + {/if} +
+ + diff --git a/web/src/lib/components/GameOverPanel.svelte b/web/src/lib/components/GameOverPanel.svelte index a87a891..b7dc92e 100644 --- a/web/src/lib/components/GameOverPanel.svelte +++ b/web/src/lib/components/GameOverPanel.svelte @@ -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}
- + {#if onrematch} + + {/if}
diff --git a/web/src/lib/components/OpponentStatus.svelte b/web/src/lib/components/OpponentStatus.svelte new file mode 100644 index 0000000..1780178 --- /dev/null +++ b/web/src/lib/components/OpponentStatus.svelte @@ -0,0 +1,63 @@ + + +{#if away} + +{/if} + + diff --git a/web/src/lib/components/RematchPrompt.svelte b/web/src/lib/components/RematchPrompt.svelte new file mode 100644 index 0000000..3fd2770 --- /dev/null +++ b/web/src/lib/components/RematchPrompt.svelte @@ -0,0 +1,87 @@ + + +{#if offer} +
+ {#if expired} +

{t.rematchExpired}

+ {:else if offer.iAccepted} +

{fill(t.rematchWaiting, { n: seconds })}

+ {:else} + {#if offer.opponentAccepted} +

{fill(t.rematchOpponentReady, { n: seconds })}

+ {:else} +

{t.rematchAsk}

+ {/if} + + {/if} +
+{/if} + + diff --git a/web/src/lib/components/RoomCodePanel.svelte b/web/src/lib/components/RoomCodePanel.svelte new file mode 100644 index 0000000..5b7e599 --- /dev/null +++ b/web/src/lib/components/RoomCodePanel.svelte @@ -0,0 +1,103 @@ + + +
+

{t.yourRoomCode}

+

{grouped}

+ +
+ + + {#if typeof navigator !== 'undefined' && 'share' in navigator} + + {/if} +
+
+ + diff --git a/web/src/lib/components/WaitingRoom.svelte b/web/src/lib/components/WaitingRoom.svelte new file mode 100644 index 0000000..8c52c35 --- /dev/null +++ b/web/src/lib/components/WaitingRoom.svelte @@ -0,0 +1,59 @@ + + +
+ + +

+ + {t.waitingForOpponent} +

+ + +
+ + diff --git a/web/src/lib/i18n/vi.js b/web/src/lib/i18n/vi.js index bd9622f..9497140 100644 --- a/web/src/lib/i18n/vi.js +++ b/web/src/lib/i18n/vi.js @@ -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.', diff --git a/web/src/lib/proto/noitu/v1/game_pb.d.ts b/web/src/lib/proto/noitu/v1/game_pb.d.ts index a73e5dc..2615cbd 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.d.ts +++ b/web/src/lib/proto/noitu/v1/game_pb.d.ts @@ -135,6 +135,24 @@ export declare type Resign = Message<"noitu.v1.Resign"> & { */ export declare const ResignSchema: GenMessage; +/** + * 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; + /** * 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; +/** + * 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; + /** * @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 }; }; diff --git a/web/src/lib/proto/noitu/v1/game_pb.js b/web/src/lib/proto/noitu/v1/game_pb.js index 053b651..5ac7541 100644 --- a/web/src/lib/proto/noitu/v1/game_pb.js +++ b/web/src/lib/proto/noitu/v1/game_pb.js @@ -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. diff --git a/web/src/lib/room-code.js b/web/src/lib/room-code.js new file mode 100644 index 0000000..5db3dbe --- /dev/null +++ b/web/src/lib/room-code.js @@ -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)) + ); +} diff --git a/web/src/lib/stores/game.svelte.js b/web/src/lib/stores/game.svelte.js index d37fd48..12eadc1 100644 --- a/web/src/lib/stores/game.svelte.js +++ b/web/src/lib/stores/game.svelte.js @@ -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; } }; } diff --git a/web/src/lib/ws/client.js b/web/src/lib/ws/client.js index 0c24d81..19055b3 100644 --- a/web/src/lib/ws/client.js +++ b/web/src/lib/ws/client.js @@ -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); }; diff --git a/web/src/lib/ws/connection.svelte.js b/web/src/lib/ws/connection.svelte.js index 0333469..624b415 100644 --- a/web/src/lib/ws/connection.svelte.js +++ b/web/src/lib/ws/connection.svelte.js @@ -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 }; diff --git a/web/src/lib/ws/messages.js b/web/src/lib/ws/messages.js index 3662657..4ad2179 100644 --- a/web/src/lib/ws/messages.js +++ b/web/src/lib/ws/messages.js @@ -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, {}) } diff --git a/web/src/routes/online/+page.svelte b/web/src/routes/online/+page.svelte index e21cecf..bbc5114 100644 --- a/web/src/routes/online/+page.svelte +++ b/web/src/routes/online/+page.svelte @@ -1,23 +1,218 @@ -
-

{t.onlineTitle}

-

{t.onlineComingSoon}

- {t.back} + {#if playing} + + {#snippet banner()} + + {/snippet} + {#snippet gameOver()} + + + send(requestRematch())} /> + {/snippet} + + {:else if game.state.phase === 'waiting'} + + {:else} +

{t.onlineTitle}

+

{t.onlineIntro}

+ + + + {#if game.state.error} + + {/if} + + + +
{ + event.preventDefault(); + join(); + }} + > + +
+ (codeError = '')} + /> + +
+

{codeError || t.roomCodeHint}

+
+ + {t.back} + {/if}
diff --git a/web/src/routes/play/+page.svelte b/web/src/routes/play/+page.svelte index 3e4b20e..98060a7 100644 --- a/web/src/routes/play/+page.svelte +++ b/web/src/routes/play/+page.svelte @@ -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 @@ } -
-
- - {difficultyLabels[difficulty]} -
- - - - {#if game.state.error} - - {/if} - - {#if game.state.phase === 'over'} + + {#snippet gameOver()} + - {:else} -
- -
-

{game.state.myTurn ? t.yourTurn : t.opponentTurn}

-

- {t.currentSyllable} - {game.state.currentSyllable || '…'} -

-
-
- - - {/if} - - - - {#if game.state.phase === 'playing'} - - {/if} -
- - + {/snippet} + diff --git a/web/tests/dictionary-source.test.js b/web/tests/dictionary-source.test.js new file mode 100644 index 0000000..57fdac6 --- /dev/null +++ b/web/tests/dictionary-source.test.js @@ -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}$/); + }); +}); diff --git a/web/tests/game-store.test.js b/web/tests/game-store.test.js index 70bd706..2e00b81 100644 --- a/web/tests/game-store.test.js +++ b/web/tests/game-store.test.js @@ -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'); + }); +}); diff --git a/web/tests/room-code.test.js b/web/tests/room-code.test.js new file mode 100644 index 0000000..1607dd0 --- /dev/null +++ b/web/tests/room-code.test.js @@ -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); + }); +}); diff --git a/web/tests/ws-client.test.js b/web/tests/ws-client.test.js index 18072d9..c07107e 100644 --- a/web/tests/ws-client.test.js +++ b/web/tests/ws-client.test.js @@ -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); + }); +}); diff --git a/web/vite.config.js b/web/vite.config.js index 64c6107..38a297e 100644 --- a/web/vite.config.js +++ b/web/vite.config.js @@ -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.