+
+
+
+
+
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 @@
-
-
-
-
- {/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.