refactor(platform): unify routing and session dispatch

This commit is contained in:
2026-09-05 05:23:58 +08:00
parent e47d9d3007
commit 1c124cf216
10 changed files with 916 additions and 171 deletions
@@ -0,0 +1,13 @@
import type { OutboundEnvelope } from '../core/types/envelope.ts';
export type OutboundMessage = OutboundEnvelope;
export type ConnectionIntent =
| { readonly type: 'none' }
| { readonly type: 'login'; readonly envelope: OutboundMessage }
| { readonly type: 'switch'; readonly target: string; readonly resend: OutboundMessage };
export interface ConnectionControlPort {
switchServer(target: string, resend: OutboundMessage): void;
stopForKick(data: unknown): void;
}
@@ -0,0 +1,107 @@
import type { GameHost } from '../sdk/contracts/index.ts';
import { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
import {
parseLoginResponse,
parseSelfJoinRoomResponse,
type ParsedSeatPayload,
type ReconnectSnapshot,
} from '../protocol/contracts/index.ts';
import { PlatformStore } from './stores/platform-store.ts';
export interface RuntimeSessionOptions {
readonly store: PlatformStore;
readonly gameSession: GameSessionHost;
readonly gameHost: GameHost;
}
/** Coordinates the canonical platform snapshot with the one active game session. */
export class RuntimeSession {
private loginIsPending = false;
private wasKicked = false;
constructor(private readonly options: RuntimeSessionOptions) {}
get loginPending(): boolean { return this.loginIsPending; }
get kicked(): boolean { return this.wasKicked; }
markLoginPending(): void {
this.requireNotKicked('mark login pending');
this.loginIsPending = true;
}
handleLogin(data: unknown): void {
this.requireNotKicked('handle login');
this.loginIsPending = false;
const parsed = parseLoginResponse(data);
this.options.store.applyLoginSuccess(parsed);
if (parsed.room === null) {
this.closeActiveGame();
return;
}
this.openCommittedRoom(parsed.reconnect);
}
handleSelfJoin(data: unknown): void {
this.requireNotKicked('handle self join');
const parsed = parseSelfJoinRoomResponse(data);
this.options.store.replaceRoom(parsed);
this.openCommittedRoom(parsed.reconnect);
}
handleSelfExit(parsed: ParsedSeatPayload): void {
this.requireNotKicked('handle self exit');
const state = this.options.store.getState();
if (state.room.kind !== 'inside') {
throw new Error('self_exit_room requires an active room');
}
if (parsed.seat !== state.room.selfSeat) {
throw new RangeError(
`self_exit_room seat mismatch: expected ${state.room.selfSeat}, received ${parsed.seat}`,
);
}
if (this.options.gameSession.state !== 'active') {
throw new Error('self_exit_room requires an active game session');
}
this.options.store.clearRoom();
this.options.gameSession.close();
}
handleServerSwitch(): void {
this.requireNotKicked('handle server switch');
this.closeActiveGame();
}
handleKick(_data: unknown): void {
this.closeActiveGame();
this.loginIsPending = false;
this.wasKicked = true;
}
private openCommittedRoom(reconnect: ReconnectSnapshot): void {
const state = this.options.store.getState();
if (state.room.kind !== 'inside') {
throw new Error('Cannot open a game session without committed room state');
}
this.closeActiveGame();
this.options.gameSession.open(this.options.gameHost);
this.options.gameSession.publish({
type: 'room.entered',
roomtype: state.room.roomtype,
});
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
}
private closeActiveGame(): void {
const state = this.options.gameSession.state;
if (state === 'idle') return;
if (state !== 'active') {
throw new Error(`Cannot close game session while ${state}`);
}
this.options.gameSession.close();
}
private requireNotKicked(operation: string): void {
if (this.wasKicked) throw new Error(`Cannot ${operation} after kick_server`);
}
}
@@ -1,17 +0,0 @@
import type { IGameModule } from '../sdk/index.ts';
/**
* 当前激活的子游戏槽(= 旧 Game_Modify 的运行时角色)。
*
* 框架在进房时 setGame(),离房时 clear()。Router 据此分发对局 route 的包。
* 单例设计:同一时刻只允许一个子游戏激活(与旧 12_Logic 一致)。
*/
export class ActiveGame {
private game: IGameModule | null = null;
get(): IGameModule | null { return this.game; }
set(game: IGameModule): void { this.game = game; }
clear(): void { this.game = null; }
}
@@ -0,0 +1,164 @@
import type { InboundMessage } from '../core/types/envelope.ts';
import type { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
import type { ConnectionControlPort } from '../platform/connection-intent.ts';
import type { RuntimeSession } from '../platform/runtime-session.ts';
import type { PlatformStore } from '../platform/stores/platform-store.ts';
import {
parseConnectAgentserverPayload,
parseConnectRoomserverPayload,
parseKickServerPayload,
parseOtherExitRoomPayload,
parseOtherJoinRoomPayload,
parseOtherOfflinePayload,
parseOtherOnlinePayload,
parsePlayerPreparePayload,
} from './contracts/index.ts';
import {
buildFirstSliceEnvelope,
FIRST_SLICE_RPC_ROUTE,
routeForFirstSlice,
type FirstSliceRpc,
} from './first-slice-routes.ts';
export interface PlatformHandlersOptions {
readonly store: PlatformStore;
readonly gameSession: GameSessionHost;
readonly session: RuntimeSession;
readonly connection: ConnectionControlPort;
}
export type PlatformDispatchResult = 'handled-platform';
/** Strict first-slice platform RPC dispatcher. */
export class PlatformHandlers {
constructor(private readonly options: PlatformHandlersOptions) {}
dispatch(message: InboundMessage): PlatformDispatchResult {
if (!isPlatformRoute(message.route)) {
throw new Error(
`PlatformHandlers received non-platform route: route=${message.route} rpc=${message.rpc}`,
);
}
if (!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE, message.rpc)) {
throw new Error(
`Unsupported platform rpc: route=${message.route} rpc=${message.rpc}`,
);
}
const rpc = message.rpc as FirstSliceRpc;
const expectedRoute = routeForFirstSlice(rpc);
if (message.route !== expectedRoute) {
throw new Error(
`Platform rpc route mismatch: rpc=${rpc} expected=${expectedRoute} received=${message.route}`,
);
}
switch (rpc) {
case 'player_login':
this.options.session.handleLogin(message.data);
break;
case 'self_join_room':
this.options.session.handleSelfJoin(message.data);
break;
case 'player_prepare': {
const parsed = parsePlayerPreparePayload(message.data);
this.requireActiveGame(rpc);
this.options.store.playerReady(parsed);
this.options.gameSession.publish({ type: 'room.player-ready', seat: parsed.seat });
break;
}
case 'self_exit_room': {
const parsed = parseOtherExitRoomPayload(message.data);
this.options.session.handleSelfExit(parsed);
break;
}
case 'other_join_room': {
const parsed = parseOtherJoinRoomPayload(message.data);
this.requireActiveGame(rpc);
this.options.store.playerJoin(parsed);
this.options.gameSession.publish({ type: 'room.player-joined', seat: parsed.seat });
break;
}
case 'other_exit_room': {
const parsed = parseOtherExitRoomPayload(message.data);
this.requireActiveGame(rpc);
this.options.store.playerExit(parsed);
this.options.gameSession.publish({ type: 'room.player-left', seat: parsed.seat });
break;
}
case 'other_offline': {
const parsed = parseOtherOfflinePayload(message.data);
this.requireActiveGame(rpc);
this.options.store.playerOffline(parsed);
this.options.gameSession.publish({ type: 'room.player-offline', seat: parsed.seat });
break;
}
case 'other_online': {
const parsed = parseOtherOnlinePayload(message.data);
this.requireActiveGame(rpc);
this.options.store.playerOnline(parsed);
this.options.gameSession.publish({ type: 'room.player-online', seat: parsed.seat });
break;
}
case 'connect_roomserver': {
const parsed = parseConnectRoomserverPayload(message.data);
const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver');
const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw);
this.options.session.handleServerSwitch();
this.options.connection.switchServer(target, resend);
break;
}
case 'connect_agentserver': {
const parsed = parseConnectAgentserverPayload(message.data);
const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver');
const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw);
this.options.session.handleServerSwitch();
this.options.connection.switchServer(target, resend);
break;
}
case 'kick_server': {
const parsed = parseKickServerPayload(message.data);
this.options.session.handleKick(parsed.raw);
this.options.connection.stopForKick(parsed.raw);
break;
}
}
return 'handled-platform';
}
private requireActiveGame(rpc: string): void {
if (this.options.gameSession.state !== 'active') {
throw new Error(`${rpc} requires an active game session`);
}
}
}
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {
return route === 'platform' || route === 'agent' || route === 'room';
}
function parseSwitchTarget(address: string, path: string): string {
if (address.length === 0 || address.trim() !== address) {
throw new TypeError(`${path}: expected a non-empty WebSocket server address`);
}
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address);
let target: string;
if (scheme) {
if (scheme[1] !== 'ws' && scheme[1] !== 'wss') {
throw new TypeError(`${path}: unsupported WebSocket scheme ${scheme[1]}`);
}
target = address;
} else {
target = `ws://${address}`;
}
let parsed: URL;
try {
parsed = new URL(target);
} catch {
throw new TypeError(`${path}: expected a valid WebSocket server address`);
}
if ((parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') || parsed.hostname.length === 0) {
throw new TypeError(`${path}: expected a valid WebSocket server address`);
}
return target;
}
@@ -1,53 +1,43 @@
import type { InboundMessage } from '../core/types/envelope.ts';
import { Route } from '../core/constants.ts';
import type { ActiveGame } from './active-game.ts';
import type { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
import type { RuntimeSession } from '../platform/runtime-session.ts';
import type { PlatformDispatchResult, PlatformHandlers } from './platform-handlers.ts';
/** 平台层 handler 表:key = rpc 字符串(与协议逐字对齐)。 */
export type PlatformHandlers = Record<string, (data: unknown) => void>;
export type RouterDispatchResult =
| 'ignored-login-gate'
| PlatformDispatchResult
| 'handled-game';
/**
* Router:收包路由分界(C §4.1 的红线)。
*
* 分发规则:
* - route ∈ {platform, agent, room} → 平台 handlers(RpcList 覆盖的包)
* - route = <game route>(= activeGame.route)→ activeGame.onReceive
* - 其他或未注册:显式抛错(第二准则:不静默吞包)
*
* 不持有状态——只做消息分发。状态由 PlatformSession/子游戏维护。
*/
export interface RouterOptions {
readonly session: Pick<RuntimeSession, 'loginPending'>;
readonly platform: Pick<PlatformHandlers, 'dispatch'>;
readonly gameSession: Pick<GameSessionHost, 'dispatchGameMessage'>;
}
/** Classifies each inbound business envelope exactly once. */
export class Router {
constructor(
private readonly opts: {
activeGame: ActiveGame;
platformHandlers: PlatformHandlers;
},
) {}
constructor(private readonly options: RouterOptions) {}
dispatch(msg: InboundMessage): void {
const { route, rpc, data } = msg;
// 平台三层 route:platform/agent/room 全部走平台 handlers
if (route === Route.platform || route === Route.agent || route === Route.room) {
const handler = this.opts.platformHandlers[rpc];
if (!handler) {
throw new Error(
`Router 收到未注册的平台 rpc: route=${route} rpc=${rpc}(platformHandlers 未注册,第二准则禁止兜底)`,
);
}
handler(data);
return;
dispatch(message: InboundMessage): RouterDispatchResult {
if (this.options.session.loginPending && !isLoginGateControl(message)) {
return 'ignored-login-gate';
}
// 对局 route:必须与 activeGame.route 严格匹配
const game = this.opts.activeGame.get();
if (!game) {
throw new Error(
`Router 收到对局包但无激活子游戏: route=${route} rpc=${rpc}(未进房或子游戏未注册)`,
);
if (isPlatformRoute(message.route)) {
return this.options.platform.dispatch(message);
}
if (game.route !== route) {
throw new Error(
`Router route 与 activeGame.route 不匹配: msg.route=${route} activeGame.route=${game.route}`,
);
}
game.onReceive(rpc, data);
this.options.gameSession.dispatchGameMessage(message.route, {
rpc: message.rpc,
data: message.data,
});
return 'handled-game';
}
}
}
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {
return route === 'platform' || route === 'agent' || route === 'room';
}
function isLoginGateControl(message: InboundMessage): boolean {
return message.route === 'agent'
&& (message.rpc === 'player_login' || message.rpc === 'kick_server');
}