refactor(platform): unify routing and session dispatch
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user