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.