Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions apps/core/noname/game/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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");
Expand Down
77 changes: 66 additions & 11 deletions apps/core/noname/library/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -10669,26 +10672,71 @@ export class Library {
}
},
onclose: function () {
clearTimeout(this._connectTimeout);
if (this._nocallback) {
return;
}
if (_status.connectCallback) {
_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);
},
},
/**
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 45 additions & 0 deletions apps/core/noname/util/reconnect.js
Original file line number Diff line number Diff line change
@@ -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 };
}
82 changes: 82 additions & 0 deletions apps/core/noname/util/reconnect.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
6 changes: 4 additions & 2 deletions apps/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
Loading
Loading