feat(framework): net-client 编排(login流程/门控/TcpID/超时重连/服务器切换/事件)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 14:24:57 +08:00
co-authored by Claude Opus 4.8
parent 79d0071645
commit 540cf286a5
2 changed files with 274 additions and 0 deletions
@@ -0,0 +1,149 @@
import { EventBus } from '../core/events.ts';
import { Route, RECV_TIMEOUT_MS, RECONNECT_INTERVAL_MS, LOGIN_GUARD_MS, SERVER_ROTATE_EVERY } from '../core/constants.ts';
import type { Transport } from './transport.ts';
import { encodeOutbound, decodeFrame } from './envelope-codec.ts';
import { HeartbeatWatchdog, realClock, type Clock } from './heartbeat.ts';
import { ReconnectPolicy } from './reconnect.ts';
import type { InboundMessage } from '../core/types/envelope.ts';
import type { LoginRequestData } from '../core/types/login.ts';
export interface NetClientEvents extends Record<string, any[]> {
open: [];
login: [unknown]; // player_login 响应 data
message: [InboundMessage];
slow: [];
reconnecting: [string]; // 目标 server
serverSwitch: [string]; // 新 server 地址
kicked: [unknown];
close: [];
}
export interface NetClientOptions {
servers: string | string[];
transportFactory: () => Transport;
clock?: Clock;
bus?: EventBus<NetClientEvents>;
}
/** 网络客户端:编排 transport/codec/heartbeat/reconnect,事件驱动,忠实 docs/protocol/01。 */
export class NetClient {
readonly bus: EventBus<NetClientEvents>;
private transportFactory: () => Transport;
private clock: Clock;
private policy: ReconnectPolicy;
private watchdog: HeartbeatWatchdog;
private transport: Transport | null = null;
private tcpId = 0; // 每次连接自增;onMessage 闭包捕获本次 id 做去重
private identity: LoginRequestData | null = null;
private isSendLoginState = false;
private isLogin = false;
private loginGuard: unknown = null;
private reconnectTimer: unknown = null;
private stopped = false;
constructor(opts: NetClientOptions) {
this.bus = opts.bus ?? new EventBus<NetClientEvents>();
this.transportFactory = opts.transportFactory;
this.clock = opts.clock ?? realClock;
this.policy = new ReconnectPolicy(opts.servers, SERVER_ROTATE_EVERY);
this.watchdog = new HeartbeatWatchdog(RECV_TIMEOUT_MS, () => this.onRecvTimeout(), this.clock);
}
setIdentity(identity: LoginRequestData): void { this.identity = identity; }
on = <K extends keyof NetClientEvents & string>(t: K, fn: (...a: NetClientEvents[K]) => void) => this.bus.on(t, fn);
off = <K extends keyof NetClientEvents & string>(t: K, fn: (...a: NetClientEvents[K]) => void) => this.bus.off(t, fn);
start(): void { this.stopped = false; this.connect(this.policy.current()); }
stop(): void {
this.stopped = true;
this.watchdog.stop();
if (this.reconnectTimer != null) { this.clock.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; }
this.transport?.close();
}
/** 发业务包(单层信封)。 */
send(route: string, rpc: string, data: unknown): void {
this.transport?.send(encodeOutbound(route, rpc, data));
}
private connect(url: string): void {
const id = ++this.tcpId;
const t = this.transportFactory();
this.transport = t;
t.onOpen(() => { if (id === this.tcpId) this.onOpen(); });
t.onMessage((frame) => { if (id === this.tcpId) this.onMessage(frame); });
t.onClose(() => { if (id === this.tcpId) this.onClose(); });
t.connect(url);
}
private onOpen(): void {
this.bus.emit('open');
this.sendLogin();
}
private sendLogin(): void {
if (!this.identity) return; // 无身份不发(docs §6.1:首登需身份)
this.isSendLoginState = true;
this.transport?.send(encodeOutbound(Route.agent, 'player_login', this.identity));
if (this.loginGuard != null) this.clock.clearTimeout(this.loginGuard);
this.loginGuard = this.clock.setTimeout(() => { this.loginGuard = null; this.onLoginGuardTimeout(); }, LOGIN_GUARD_MS); // docs §6.2
}
private onMessage(frame: string): void {
const r = decodeFrame(frame);
if (r.kind === 'ignore') return;
this.watchdog.feed(); // 收任意有效帧重置看门狗 docs §4.1
if (r.kind === 'handshake' || r.kind === 'heartbeat') return; // 忽略,心跳不回包
if (r.kind === 'serverDown') { this.sendLogin(); return; } // docs §4.2(剥离 UI 判定,直接重发登录)
const msg = r.message;
if (msg.rpc === 'submit_error') return; // docs §3.2-9
if (this.isSendLoginState) { // docs §3.2-10 登录态门控
if (msg.rpc === 'player_login') {
this.isSendLoginState = false; this.isLogin = true;
if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; }
this.policy.reset();
this.bus.emit('login', msg.data);
} else if (msg.rpc === 'kick_server') {
this.bus.emit('kicked', msg.data);
}
return; // 其它一律丢弃
}
// 服务器切换指令 docs §7.4
if (msg.rpc === 'connect_roomserver' && (msg.data as any)?.roomserver) {
this.switchServer((msg.data as any).roomserver); return;
}
if (msg.rpc === 'connect_agentserver' && (msg.data as any)?.agentserver) {
this.switchServer((msg.data as any).agentserver); return;
}
this.bus.emit('message', msg);
}
private switchServer(addr: string): void {
this.policy.setCurrent(addr);
this.bus.emit('serverSwitch', addr);
this.reconnectNow(); // 关闭当前→重连到新地址→重登
}
private onRecvTimeout(): void { this.bus.emit('slow'); this.reconnectNow(); } // docs §4.1
private onLoginGuardTimeout(): void { this.reconnectNow(); } // docs §6.2
private onClose(): void {
this.watchdog.stop();
if (this.stopped) return;
const next = this.policy.onFailure();
this.bus.emit('reconnecting', next);
if (this.reconnectTimer != null) this.clock.clearTimeout(this.reconnectTimer);
this.reconnectTimer = this.clock.setTimeout(() => { this.reconnectTimer = null; this.connect(next); }, RECONNECT_INTERVAL_MS); // docs §7.2 间隔 10s
}
/** 主动关闭当前连接,交由 onClose 走重连。 */
private reconnectNow(): void {
this.isSendLoginState = false;
if (this.loginGuard != null) { this.clock.clearTimeout(this.loginGuard); this.loginGuard = null; }
this.transport?.close();
}
}