From ccaa941c1feceec78157c445d65df2d36ce5c90f Mon Sep 17 00:00:00 2001 From: zhpy <2254673732@qq.com> Date: Sun, 31 May 2026 02:52:12 -0700 Subject: [PATCH 1/6] =?UTF-8?q?feat(online):=20=E5=A2=9E=E5=BC=BA=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E8=BF=9E=E6=8E=A5=E9=B2=81=E6=A3=92=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - game.send() 增加 WebSocket readyState 检查,避免 CLOSING/CLOSED 时 send 抛 InvalidStateError - game.connect() 增加 10s 连接超时,避免服务端无响应时无限等待 - 断线自动重连:指数退避 1s→2s→4s→8s→15s,共 5 次,全部失败回退到 原有刷新行为;用独立 _status.reconnecting 标志驱动续连,修复纯大厅 场景下重试链中断、重连遮罩卡住的问题 - broadcast() 改为快照迭代,避免 client.send 失败触发 close 移除自身 导致迭代漏发 - selfclose 取消自动重连并清理 reconnect_info,防止退出后误重连 - 重连决策逻辑抽离为纯函数 decideReconnect (@/util/reconnect.js) 并 新增 vitest 单测(11 用例) --- apps/core/noname/game/index.js | 19 +- apps/core/noname/library/index.js | 77 ++++++-- apps/core/noname/util/reconnect.js | 45 +++++ apps/core/noname/util/reconnect.test.js | 82 ++++++++ apps/core/package.json | 6 +- apps/core/pnpm-lock.yaml | 246 ++++++++++++++++++++++++ apps/core/vite.config.ts | 6 +- 7 files changed, 465 insertions(+), 16 deletions(-) create mode 100644 apps/core/noname/util/reconnect.js create mode 100644 apps/core/noname/util/reconnect.test.js diff --git a/apps/core/noname/game/index.js b/apps/core/noname/game/index.js index 0360abb484..8a4a8624ec 100644 --- a/apps/core/noname/game/index.js +++ b/apps/core/noname/game/index.js @@ -1903,7 +1903,9 @@ export class Game { if (!lib.node || !lib.node.clients || game.online) { return; } - for (const client of lib.node.clients) { + // 快照迭代:client.send() 失败会触发 client.close(),后者从 + // lib.node.clients 中移除自身,直接迭代原数组会跳过元素 + for (const client of [...lib.node.clients]) { client.send(func, ...args); } } @@ -2184,13 +2186,26 @@ export class Game { game.ws.onmessage = lib.element.ws.onmessage; game.ws.onerror = lib.element.ws.onerror; game.ws.onclose = lib.element.ws.onclose; + // 连接超时:服务端无响应时避免无限等待(onopen/onerror/onclose 中清除) + game.ws._connectTimeout = setTimeout(() => { + if (game.ws && game.ws.readyState !== 1) { + game.ws._nocallback = true; + game.ws.close(); + if (callback) { + callback(false); + } + delete _status.connectCallback; + } + }, 10000); _status.ip = ip; } send() { if (game.observe && arguments[0] != "reinited") { return; } - if (game.ws) { + // readyState 必须为 OPEN(1);CLOSING(2)/CLOSED(3) 时 game.ws 仍非空, + // 直接 send 会抛 InvalidStateError + if (game.ws && game.ws.readyState === 1) { const args = Array.from(arguments); if (typeof args[0] == "function") { args.unshift("exec"); diff --git a/apps/core/noname/library/index.js b/apps/core/noname/library/index.js index a0350f85b7..c3443c1a3f 100644 --- a/apps/core/noname/library/index.js +++ b/apps/core/noname/library/index.js @@ -17,6 +17,7 @@ import { updateURLs } from "./update-urls.js"; import { defaultHooks } from "./hooks/index.js"; import { security, ErrorManager } from "@/util/sandbox.js"; import { assetURL, userAgentLowerCase, GeneratorFunction, AsyncFunction, characterDefaultPicturePath } from "@/util/index.js"; +import { decideReconnect } from "@/util/reconnect.js"; import { defaultSplashs } from "@/init/onload/index.js"; import dedent from "dedent"; @@ -10622,6 +10623,7 @@ export class Library { Character: Element.Character, ws: { onopen: function () { + clearTimeout(this._connectTimeout); if (_status.connectCallback) { _status.connectCallback(true); delete _status.connectCallback; @@ -10658,6 +10660,7 @@ export class Library { lib.message.client[message.shift()].apply(null, message); }, onerror: function (e) { + clearTimeout(this._connectTimeout); if (this._nocallback) { return; } @@ -10669,6 +10672,7 @@ export class Library { } }, onclose: function () { + clearTimeout(this._connectTimeout); if (this._nocallback) { return; } @@ -10676,19 +10680,63 @@ export class Library { _status.connectCallback(false); delete _status.connectCallback; } - if (game.online || game.onlineroom) { - if ((game.servermode || game.onlinehall) && _status.over) { - void 0; - } else { - localStorage.setItem(lib.configprefix + "directstart", true); - game.reload(); - } - } else { - // game.saveConfig('reconnect_info'); - } - game.online = false; + // 清理 WebSocket 引用 game.ws = null; game.sandbox = null; + + var wasOnline = game.online || game.onlineroom; + var wasGameOver = (game.servermode || game.onlinehall) && _status.over; + + // 【关键】必须先置 game.online = false,否则 game.connect() 会因 + // 其开头的 `if (game.online) return;` 守卫而跳过重连 + game.online = false; + + // 决策逻辑抽离为纯函数 decideReconnect(见 @/util/reconnect.js), + // 此处只负责按返回的 action 执行副作用 + var decision = decideReconnect({ + wasOnline: wasOnline, + wasGameOver: wasGameOver, + noReconnect: !!_status.noReconnect, + reconnecting: !!_status.reconnecting, + hasIp: !!_status.ip, + attempts: _status.reconnectAttempts || 0, + maxAttempts: 5, + }); + + if (decision.action === "none") { + // 未联机或游戏正常结束,无需重连 + return; + } + if (decision.action === "reload") { + // 主动退出 / 无地址 / 重试耗尽 —— 回退到原有刷新行为 + delete _status.noReconnect; + _status.reconnecting = false; + _status.reconnectAttempts = 0; + localStorage.setItem(lib.configprefix + "directstart", true); + game.reload(); + return; + } + // decision.action === "retry":指数退避调度下一次重连 + _status.reconnectAttempts = decision.nextAttempts; + _status.reconnecting = true; + // 显示重连遮罩层(connecting(true) 用于移除) + if (typeof ui.create.connecting === "function") { + ui.create.connecting(); + } + _status.reconnectTimer = setTimeout(function () { + game.connect(_status.ip, function (success) { + if (success) { + _status.reconnectAttempts = 0; + _status.reconnecting = false; + if (typeof ui.create.connecting === "function") { + ui.create.connecting(true); + } + // 服务端通过 roomlist 触发,客户端携带 reconnect_info 自动重新加入房间 + } + // 失败时不在此处理:新建连接的 onclose 因 _status.reconnecting + // 仍为 true 而继续进入重试链,依据 reconnectAttempts 退避或回退刷新 + }); + }, decision.delay); }, }, /** @@ -12527,11 +12575,18 @@ export class Library { } }, selfclose: function () { + // 用户被踢出 / 房主断开:取消自动重连,并标记走 reload 路径 + clearTimeout(_status.reconnectTimer); + _status.reconnectAttempts = 0; + _status.reconnecting = false; + _status.noReconnect = true; if (game.online || game.onlineroom) { if ((game.servermode || game.onlinehall) && _status.over) { // later } else { game.saveConfig("tmp_user_roomId"); + // 清除重连信息,防止重连后尝试加入已不存在的房间 + game.saveConfig("reconnect_info"); } } game.ws.close(); diff --git a/apps/core/noname/util/reconnect.js b/apps/core/noname/util/reconnect.js new file mode 100644 index 0000000000..9d42ecf4e1 --- /dev/null +++ b/apps/core/noname/util/reconnect.js @@ -0,0 +1,45 @@ +/** + * 联机断线重连的纯决策逻辑。 + * + * 从 `lib.element.ws.onclose` 中抽离,仅根据传入状态返回应执行的动作, + * 不含任何副作用(setTimeout / game.connect / game.reload / DOM 操作), + * 因此可被单元测试覆盖。副作用由调用方(onclose)根据返回的 action 执行。 + * + * @typedef {Object} ReconnectState + * @property {boolean} wasOnline 断开前是否处于联机态(game.online || game.onlineroom) + * @property {boolean} wasGameOver 是否为正常游戏结束(无需重连) + * @property {boolean} noReconnect 是否用户主动退出 / 被踢(走 reload,不重连) + * @property {boolean} reconnecting 是否正处于重试链中(用于纯大厅等 wasOnline 已为 false 的续连) + * @property {boolean} hasIp 是否存在可重连地址(_status.ip) + * @property {number} attempts 已尝试重连次数(_status.reconnectAttempts) + * @property {number} [maxAttempts=5] 最大重试次数 + * + * @typedef {{action: "none"} + * | {action: "reload"} + * | {action: "retry", delay: number, nextAttempts: number}} ReconnectDecision + * + * @param {ReconnectState} state + * @returns {ReconnectDecision} + */ +export function decideReconnect(state) { + const { wasOnline, wasGameOver, noReconnect, reconnecting, hasIp } = state; + const attempts = state.attempts || 0; + const maxAttempts = state.maxAttempts || 5; + + // 是否需要重连:本次为联机中的异常断开,或正处于重试链中(续连)。 + // 用独立的 reconnecting 标志驱动续连,避免依赖 onlineroom/online —— 纯大厅 + // 场景下二者在重试期间均为 false,否则重试链会中断、重连遮罩卡住。 + const shouldReconnect = (wasOnline && !wasGameOver) || reconnecting; + if (!shouldReconnect) { + return { action: "none" }; + } + + // 用户主动退出 / 被踢、无可重连地址、或重试已耗尽 —— 回退到刷新 + if (noReconnect || !hasIp || attempts >= maxAttempts) { + return { action: "reload" }; + } + + // 指数退避:1s → 2s → 4s → 8s → 15s(封顶 15s) + const delay = Math.min(1000 * Math.pow(2, attempts), 15000); + return { action: "retry", delay, nextAttempts: attempts + 1 }; +} diff --git a/apps/core/noname/util/reconnect.test.js b/apps/core/noname/util/reconnect.test.js new file mode 100644 index 0000000000..681b96d8fd --- /dev/null +++ b/apps/core/noname/util/reconnect.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; + +import { decideReconnect } from "./reconnect.js"; + +/** + * decideReconnect 是从 lib.element.ws.onclose 中抽出的纯决策函数, + * 不含任何副作用(setTimeout / game.reload / DOM),便于单测。 + */ +describe("decideReconnect", () => { + /** 构造一个“在线且需要重连”的基础状态,单测时按需覆盖字段 */ + const base = { + wasOnline: true, + wasGameOver: false, + noReconnect: false, + reconnecting: false, + hasIp: true, + attempts: 0, + maxAttempts: 5, + }; + + it("未联机且不在重连链中 → 不处理(none)", () => { + expect(decideReconnect({ ...base, wasOnline: false }).action).toBe("none"); + }); + + it("游戏正常结束 → 不处理(none)", () => { + expect(decideReconnect({ ...base, wasGameOver: true }).action).toBe("none"); + }); + + it("用户主动退出 / 被踢(noReconnect)→ 回退刷新(reload)", () => { + expect(decideReconnect({ ...base, noReconnect: true }).action).toBe("reload"); + }); + + it("无可重连地址 → 回退刷新(reload)", () => { + expect(decideReconnect({ ...base, hasIp: false }).action).toBe("reload"); + }); + + it("重试次数达到上限 → 回退刷新(reload)", () => { + expect(decideReconnect({ ...base, attempts: 5 }).action).toBe("reload"); + }); + + it("首次断线 → 重试,延迟 1s,下次尝试数 1", () => { + const d = decideReconnect({ ...base, attempts: 0 }); + expect(d.action).toBe("retry"); + expect(d.delay).toBe(1000); + expect(d.nextAttempts).toBe(1); + }); + + it("指数退避:1s → 2s → 4s → 8s → 15s(封顶)", () => { + const delayAt = a => decideReconnect({ ...base, attempts: a }).delay; + expect(delayAt(0)).toBe(1000); + expect(delayAt(1)).toBe(2000); + expect(delayAt(2)).toBe(4000); + expect(delayAt(3)).toBe(8000); + expect(delayAt(4)).toBe(15000); // 16000 封顶为 15000 + }); + + it("纯大厅续连:wasOnline=false 但 reconnecting=true 仍继续重试(核心修复)", () => { + const d = decideReconnect({ + ...base, + wasOnline: false, + reconnecting: true, + attempts: 2, + }); + expect(d.action).toBe("retry"); + expect(d.delay).toBe(4000); + expect(d.nextAttempts).toBe(3); + }); + + it("续连耗尽:reconnecting=true 且 attempts 达上限 → 回退刷新", () => { + expect(decideReconnect({ ...base, wasOnline: false, reconnecting: true, attempts: 5 }).action).toBe("reload"); + }); + + it("maxAttempts 缺省为 5", () => { + const s = { ...base, attempts: 5 }; + delete s.maxAttempts; + expect(decideReconnect(s).action).toBe("reload"); + }); + + it("noReconnect 优先于重试(即便有 ip、次数未满)", () => { + expect(decideReconnect({ ...base, noReconnect: true, attempts: 0 }).action).toBe("reload"); + }); +}); diff --git a/apps/core/package.json b/apps/core/package.json index f1867ca135..b6e5066c54 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -23,7 +23,8 @@ "build": "tsx scripts/build.ts", "build:types": "tsc -p tsconfig.types.json", "generateAsset": "tsx scripts/generateAsset.ts", - "lint": "pnpm eslint noname/**" + "lint": "pnpm eslint noname/**", + "test": "vitest run" }, "dependencies": { "@codemirror/autocomplete": "^6.20.0", @@ -72,6 +73,7 @@ "fs-extra": "^11.3.3", "glob": "^13.0.3", "vite": "^7.3.1", - "vite-plugin-static-copy": "^3.2.0" + "vite-plugin-static-copy": "^3.2.0", + "vitest": "^4.1.4" } } diff --git a/apps/core/pnpm-lock.yaml b/apps/core/pnpm-lock.yaml index eb7da8c2f7..46424b1ca9 100644 --- a/apps/core/pnpm-lock.yaml +++ b/apps/core/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: vite-plugin-static-copy: specifier: ^3.2.0 version: 3.2.0(vite@7.3.1(@types/node@25.2.3)) + vitest: + specifier: ^4.1.4 + version: 4.1.7(@types/node@25.2.3)(vite@7.3.1(@types/node@25.2.3)) packages: @@ -537,12 +540,21 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cordova@11.0.3': resolution: {integrity: sha512-kyuRQ40/NWQVhqGIHq78Ehu2Bf9Mlg0LhmSmis6ZFJK7z933FRfYi8tHe/k/0fB+PGfCf95rJC6TO7dopaFvAg==} '@types/crypto-js@4.2.2': resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -565,6 +577,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 vue: ^3.2.25 + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@vue/compiler-core@3.5.28': resolution: {integrity: sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==} @@ -606,6 +647,10 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@4.0.2: resolution: {integrity: sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==} engines: {node: 20 || >=22} @@ -622,6 +667,10 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -649,6 +698,9 @@ packages: engines: {node: '>=18'} hasBin: true + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + core-js-bundle@3.48.0: resolution: {integrity: sha512-e1WrUdQaoOaRfcvIe9iZynVWwmuvzxJOMrRzTj4YIyw6PGqAe1fjTK/9bh3OeHXY0muase+JoTHN83w1TucnJQ==} @@ -688,6 +740,9 @@ packages: error-stack-parser@2.1.4: resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -703,6 +758,13 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -804,6 +866,9 @@ packages: nosleep.js@0.12.0: resolution: {integrity: sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + p-map@7.0.4: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} @@ -822,6 +887,9 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -871,6 +939,9 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -882,6 +953,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stackframe@1.3.4: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} @@ -891,6 +965,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -910,10 +987,21 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.3: + resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -978,6 +1066,47 @@ packages: yaml: optional: true + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vue@3.5.28: resolution: {integrity: sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==} peerDependencies: @@ -994,6 +1123,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1339,10 +1473,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true + '@standard-schema/spec@1.1.0': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/cordova@11.0.3': {} '@types/crypto-js@4.2.2': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/fs-extra@11.0.4': @@ -1368,6 +1511,47 @@ snapshots: vite: 7.3.1(@types/node@25.2.3) vue: 3.5.28 + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@7.3.1(@types/node@25.2.3))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.2.3) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.28': dependencies: '@babel/parser': 7.29.0 @@ -1433,6 +1617,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + assertion-error@2.0.1: {} + balanced-match@4.0.2: dependencies: jackspeak: 4.2.3 @@ -1447,6 +1633,8 @@ snapshots: dependencies: fill-range: 7.1.1 + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -1495,6 +1683,8 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + convert-source-map@2.0.0: {} + core-js-bundle@3.48.0: {} crelt@1.0.6: {} @@ -1524,6 +1714,8 @@ snapshots: dependencies: stackframe: 1.3.4 + es-module-lexer@2.1.0: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -1559,6 +1751,12 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -1640,6 +1838,8 @@ snapshots: nosleep.js@0.12.0: {} + obug@2.1.1: {} + p-map@7.0.4: {} pako@1.0.11: {} @@ -1653,6 +1853,8 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.2 + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -1718,6 +1920,8 @@ snapshots: shell-quote@1.8.3: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map@0.5.6: {} @@ -1726,6 +1930,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stackback@0.0.2: {} + stackframe@1.3.4: {} stacktrace-gps@3.1.2: @@ -1739,6 +1945,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + std-env@4.1.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -1759,11 +1967,17 @@ snapshots: dependencies: has-flag: 4.0.0 + tinybench@2.9.0: {} + + tinyexec@1.2.3: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -1796,6 +2010,33 @@ snapshots: '@types/node': 25.2.3 fsevents: 2.3.3 + vitest@4.1.7(@types/node@25.2.3)(vite@7.3.1(@types/node@25.2.3)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.1(@types/node@25.2.3)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.3 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.2.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.2.3 + transitivePeerDependencies: + - msw + vue@3.5.28: dependencies: '@vue/compiler-dom': 3.5.28 @@ -1810,6 +2051,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/apps/core/vite.config.ts b/apps/core/vite.config.ts index 898f5adafe..0ee68fe3e8 100644 --- a/apps/core/vite.config.ts +++ b/apps/core/vite.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import vue from "@vitejs/plugin-vue"; const port = { @@ -17,6 +17,10 @@ export default defineConfig({ }, }, plugins: [vue()], + test: { + // 只收集源码下的测试,避免误收 dist/ 构建产物中的同名测试文件 + include: ["noname/**/*.test.js"], + }, server: { host: "127.0.0.1", port: port.client, From ca11d12bd2ce9b202e3a4cd1ba6b1e0bdab4cbf3 Mon Sep 17 00:00:00 2001 From: rintim Date: Tue, 16 Jun 2026 15:47:45 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=E6=88=96=E8=AE=B8=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E8=B6=85=E6=97=B6=E6=97=B6=E5=AD=98=E5=9C=A8?= =?UTF-8?q?=E5=9B=9E=E8=B0=83=E5=87=BD=E6=95=B0=E4=B8=8D=E5=90=8C=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E6=B8=85=E9=99=A4=E6=83=85=E5=86=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/noname/game/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/core/noname/game/index.js b/apps/core/noname/game/index.js index 9ae750975d..8eb63d772f 100644 --- a/apps/core/noname/game/index.js +++ b/apps/core/noname/game/index.js @@ -2191,10 +2191,10 @@ export class Game { if (game.ws && game.ws.readyState !== 1) { game.ws._nocallback = true; game.ws.close(); - if (callback) { + if (callback && _status.connectCallback === callback) { callback(false); + delete _status.connectCallback; } - delete _status.connectCallback; } }, 10000); _status.ip = ip; From 5d8670271b9583b58ae0cb07b482d794817ac114 Mon Sep 17 00:00:00 2001 From: rintim Date: Tue, 16 Jun 2026 15:50:13 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E7=A1=AE=E4=BF=9DdecideReconnect?= =?UTF-8?q?=E5=8F=AA=E6=9C=89=E6=9C=AA=E5=90=AB=E6=9C=89=E5=80=BC=E7=9A=84?= =?UTF-8?q?=E6=83=85=E5=86=B5=E4=B8=8B=E6=89=8D=E4=BD=BF=E7=94=A8=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/noname/util/reconnect.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/core/noname/util/reconnect.js b/apps/core/noname/util/reconnect.js index 9d42ecf4e1..1f8a5001c3 100644 --- a/apps/core/noname/util/reconnect.js +++ b/apps/core/noname/util/reconnect.js @@ -23,8 +23,8 @@ */ export function decideReconnect(state) { const { wasOnline, wasGameOver, noReconnect, reconnecting, hasIp } = state; - const attempts = state.attempts || 0; - const maxAttempts = state.maxAttempts || 5; + const attempts = state.attempts ?? 0; + const maxAttempts = state.maxAttempts ?? 5; // 是否需要重连:本次为联机中的异常断开,或正处于重试链中(续连)。 // 用独立的 reconnecting 标志驱动续连,避免依赖 onlineroom/online —— 纯大厅 From d9e5d88843470cb08168364bae26266b507d2a54 Mon Sep 17 00:00:00 2001 From: rintim Date: Tue, 16 Jun 2026 15:59:08 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=E9=98=B2=E6=AD=A2=E6=97=A7=E7=9A=84?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E8=BF=9E=E6=8E=A5=E5=85=B3=E9=97=AD=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/noname/game/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/core/noname/game/index.js b/apps/core/noname/game/index.js index 8eb63d772f..d7d98ec711 100644 --- a/apps/core/noname/game/index.js +++ b/apps/core/noname/game/index.js @@ -2168,6 +2168,7 @@ export class Game { _status.connectCallback = callback; try { if (game.ws) { + clearTimeout(game.ws._connectTimeout); game.ws._nocallback = true; game.ws.close(); delete game.ws; From 198e83c347e5d499d6127eda01ae6e1bebac7dd0 Mon Sep 17 00:00:00 2001 From: rintim Date: Tue, 16 Jun 2026 16:08:24 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=8F=8D=E5=A4=8D?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E5=8F=AF=E8=83=BD=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E5=A4=9A=E9=87=8D=E9=81=AE=E7=BD=A9=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/noname/ui/create/index.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/core/noname/ui/create/index.js b/apps/core/noname/ui/create/index.js index 192b3973f8..cdb2d37942 100644 --- a/apps/core/noname/ui/create/index.js +++ b/apps/core/noname/ui/create/index.js @@ -804,9 +804,11 @@ export class Create { } } else { ui.window.classList.add("connecting"); - ui.connecting = ui.create.div(".fullsize.connectlayer"); - document.body.appendChild(ui.connecting); - ui.create.div("", "正在重连...", ui.connecting); + if (!ui.connecting) { + ui.connecting = ui.create.div(".fullsize.connectlayer"); + document.body.appendChild(ui.connecting); + ui.create.div("", "正在重连...", ui.connecting); + } ui.connecting.splashtimeout = setTimeout(function () { if (ui.connecting) { delete ui.connecting.splashtimeout; From 2f6a82a72e89161beeb9ec4d74c49ae5138ada0c Mon Sep 17 00:00:00 2001 From: rintim Date: Tue, 16 Jun 2026 16:18:52 +0800 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20=E8=AE=A9=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E8=BF=9E=E6=8E=A5=E8=B5=B0=E6=AD=A3=E5=B8=B8=E7=9A=84?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/core/noname/game/index.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/core/noname/game/index.js b/apps/core/noname/game/index.js index d7d98ec711..40e8415e09 100644 --- a/apps/core/noname/game/index.js +++ b/apps/core/noname/game/index.js @@ -2168,7 +2168,6 @@ export class Game { _status.connectCallback = callback; try { if (game.ws) { - clearTimeout(game.ws._connectTimeout); game.ws._nocallback = true; game.ws.close(); delete game.ws; @@ -2187,15 +2186,11 @@ export class Game { game.ws.onmessage = lib.element.ws.onmessage; game.ws.onerror = lib.element.ws.onerror; game.ws.onclose = lib.element.ws.onclose; - // 连接超时:服务端无响应时避免无限等待(onopen/onerror/onclose 中清除) - game.ws._connectTimeout = setTimeout(() => { - if (game.ws && game.ws.readyState !== 1) { - game.ws._nocallback = true; - game.ws.close(); - if (callback && _status.connectCallback === callback) { - callback(false); - delete _status.connectCallback; - } + // 连接超时处理 + const ws = game.ws; + ws._connectTimeout = setTimeout(() => { + if (game.ws === ws && ws.readyState !== 1) { + ws.close(); } }, 10000); _status.ip = ip;