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');
}
@@ -1,8 +1,11 @@
import { afterEach, test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs';
const roots = [];
@@ -30,6 +33,85 @@ function scan(root) {
});
}
const frameworkRoot = fileURLToPath(new URL('../../YouleNexus/assets/framework/', import.meta.url));
const modernRuntimeEntries = [
'net/wire-client.ts',
'platform/runtime-session.ts',
'protocol/platform-handlers.ts',
'protocol/router.ts',
// Task 10 creates this entry; the same assertion starts covering it as soon as it exists.
'platform/runtime.ts',
];
const legacyRuntimeFiles = new Set([
'net/net-client.ts',
'platform/session.ts',
'platform/startup.ts',
'platform/room-rpc-bus.ts',
'platform/readonly.ts',
'platform/stores/app-store.ts',
'platform/stores/player-store.ts',
'platform/stores/room-store.ts',
'platform/stores/types.ts',
'protocol/room-handlers.ts',
].map((file) => resolve(frameworkRoot, file)));
function relativeImports(file) {
const source = readFileSync(file, 'utf8');
const sourceFile = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS);
const imports = [];
const visit = (node) => {
let specifier;
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node))
&& node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
specifier = node.moduleSpecifier.text;
} else if (ts.isImportTypeNode(node)
&& ts.isLiteralTypeNode(node.argument)
&& ts.isStringLiteral(node.argument.literal)) {
specifier = node.argument.literal.text;
} else if (ts.isCallExpression(node)
&& node.expression.kind === ts.SyntaxKind.ImportKeyword
&& node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0])) {
specifier = node.arguments[0].text;
}
if (specifier?.startsWith('.')) {
const imported = resolve(dirname(file), specifier);
const candidates = [imported, `${imported}.ts`, join(imported, 'index.ts')];
const resolvedImport = candidates.find((candidate) => existsSync(candidate));
if (resolvedImport) imports.push(resolvedImport);
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sourceFile, visit);
return imports;
}
function findLegacyDependency(entry, path = [], visited = new Set()) {
if (legacyRuntimeFiles.has(entry)) return [...path, entry];
if (visited.has(entry)) return null;
visited.add(entry);
for (const imported of relativeImports(entry)) {
const found = findLegacyDependency(imported, [...path, entry], visited);
if (found) return found;
}
return null;
}
test('every existing modern runtime entry is transitively isolated from the legacy compatibility island', () => {
const entries = modernRuntimeEntries
.map((file) => resolve(frameworkRoot, file))
.filter((file) => existsSync(file));
assert.ok(entries.some((file) => file.endsWith(`${join('protocol', 'router.ts')}`)));
for (const entry of entries) {
const dependencyPath = findLegacyDependency(entry);
assert.equal(
dependencyPath,
null,
dependencyPath?.map((file) => file.slice(frameworkRoot.length)).join(' -> '),
);
}
});
test('scanner rejects sdk contracts importing a framework platform path', async () => {
const root = await createFixtureRoot();
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
@@ -0,0 +1,111 @@
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type {
GameEntry,
GameHost,
GameModule,
PlatformGameSnapshot,
PlatformToGameEvent,
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
function fixture(name: string): Record<string, unknown> {
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data;
}
function makeSession() {
const store = new PlatformStore();
const log: string[] = [];
const restored: unknown[] = [];
const events: Array<{ event: PlatformToGameEvent; snapshot: PlatformGameSnapshot }> = [];
let host: GameHost;
const entry: GameEntry = {
key: 'fixture',
gameId: 41,
route: 'fixture-game',
resolveSeatCount: () => 4,
createModule: (): GameModule => ({
attach(attachedHost) {
host = attachedHost;
attachedHost.getSnapshot();
log.push('attach');
},
handlePlatformEvent(event) {
events.push({ event, snapshot: host.getSnapshot() });
log.push(`event:${event.type}`);
},
handleGameMessage() {},
restore(deskinfo) {
restored.push(deskinfo);
log.push('restore');
},
dispose() { log.push('dispose'); },
}),
};
const gameSession = new GameSessionHost(entry);
log.length = 0;
const gameHost: GameHost = {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => selectGameSnapshot(store.getState()),
subscribe: () => () => {},
sendGameMessage() {},
execute() {},
};
const session = new RuntimeSession({ store, gameSession, gameHost });
return { store, gameSession, session, log, restored, events };
}
test('RuntimeSession commits a room login before opening, publishing, and restoring deskinfo', () => {
const setup = makeSession();
const data = fixture('player-login-room.json');
setup.store.subscribe(() => { setup.log.push('store'); });
setup.session.markLoginPending();
setup.session.handleLogin(data);
assert.equal(setup.session.loginPending, false);
assert.equal(setup.store.getState().room.kind, 'inside');
assert.equal('deskinfo' in setup.store.getState().room, false);
assert.equal(setup.restored.length, 1);
assert.equal(setup.restored[0], data.deskinfo);
assert.deepEqual(setup.log, ['store', 'attach', 'event:room.entered', 'restore']);
assert.equal(setup.events[0]?.snapshot.room.roomcode, '100001');
assert.equal(setup.events[0]?.event.type, 'room.entered');
if (setup.events[0]?.event.type === 'room.entered') {
assert.equal(setup.events[0].event.roomtype, setup.events[0].snapshot.room.roomtype);
}
});
test('RuntimeSession never treats isbattle as a restore signal without truthy deskinfo', () => {
for (const deskinfo of [undefined, null, false, 0, '']) {
const setup = makeSession();
const data = fixture('player-login-room.json');
if (deskinfo === undefined) delete data.deskinfo;
else data.deskinfo = deskinfo;
data.isbattle = 1;
setup.session.handleLogin(data);
assert.deepEqual(setup.restored, [], `deskinfo=${String(deskinfo)}`);
assert.equal(setup.store.getState().room.kind, 'inside');
}
});
test('RuntimeSession self join commits, opens a fresh session, and restores the same snapshot once', () => {
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-success.json'));
setup.log.length = 0;
const join = fixture('self-join-room.json');
setup.store.subscribe(() => { setup.log.push('store'); });
setup.session.handleSelfJoin(join);
assert.equal(setup.store.getState().room.kind, 'inside');
assert.equal(setup.restored.length, 1);
assert.equal(setup.restored[0], join.deskinfo);
assert.deepEqual(setup.log, ['store', 'attach', 'event:room.entered', 'restore']);
});
@@ -1,40 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ActiveGame } from '../../YouleNexus/assets/framework/protocol/active-game.ts';
import type { IGameModule, GameContext } from '../../YouleNexus/assets/framework/sdk/index.ts';
class MockGame implements IGameModule {
readonly route = 'mock';
events: string[] = [];
onEnter(_ctx: GameContext): void { this.events.push('onEnter'); }
onExit(): void { this.events.push('onExit'); }
onReceive(rpc: string, _d: unknown): void { this.events.push(`onReceive:${rpc}`); }
onReconnect(_d: unknown): void { this.events.push('onReconnect'); }
}
test('初始: getGame 返回 null', () => {
assert.equal(new ActiveGame().get(), null);
});
test('setGame: 设置后 get 返回同一实例', () => {
const slot = new ActiveGame();
const g = new MockGame();
slot.set(g);
assert.equal(slot.get(), g);
});
test('clear: 清空后 get 返回 null', () => {
const slot = new ActiveGame();
slot.set(new MockGame());
slot.clear();
assert.equal(slot.get(), null);
});
test('set 覆盖前一个 game', () => {
const slot = new ActiveGame();
const g1 = new MockGame();
const g2 = new MockGame();
slot.set(g1);
slot.set(g2);
assert.equal(slot.get(), g2);
});
@@ -0,0 +1,244 @@
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
import type {
GameEntry,
GameHost,
GameModule,
PlatformGameSnapshot,
PlatformToGameEvent,
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
import type { ConnectionControlPort } from '../../YouleNexus/assets/framework/platform/connection-intent.ts';
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
import { PlatformHandlers } from '../../YouleNexus/assets/framework/protocol/platform-handlers.ts';
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
function fixture(name: string): Record<string, unknown> {
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data;
}
class ConnectionFixture implements ConnectionControlPort {
readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = [];
readonly stopCalls: unknown[] = [];
switchServer(target: string, resend: OutboundEnvelope): void {
this.switchCalls.push({ target, resend });
}
stopForKick(data: unknown): void { this.stopCalls.push(data); }
}
function makeHandlers() {
const store = new PlatformStore();
let disposals = 0;
let eventObserver: (
event: PlatformToGameEvent,
snapshot: PlatformGameSnapshot,
) => void = () => {};
let disposalObserver: () => void = () => {};
const entry: GameEntry = {
key: 'fixture', gameId: 41, route: 'fixture-game', resolveSeatCount: () => 4,
createModule: (): GameModule => ({
attach() {},
handlePlatformEvent(event) {
eventObserver(event, selectGameSnapshot(store.getState()));
},
handleGameMessage() {},
restore() {},
dispose() { disposals += 1; disposalObserver(); },
}),
};
const gameSession = new GameSessionHost(entry);
disposals = 0;
const gameHost: GameHost = {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => selectGameSnapshot(store.getState()),
subscribe: () => () => {}, sendGameMessage() {}, execute() {},
};
const session = new RuntimeSession({ store, gameSession, gameHost });
const connection = new ConnectionFixture();
const handlers = new PlatformHandlers({ store, gameSession, session, connection });
const router = new Router({ session, platform: handlers, gameSession });
return {
store,
gameSession,
session,
connection,
handlers,
router,
getDisposals: () => disposals,
observeEvent(observer: typeof eventObserver) { eventObserver = observer; },
observeDisposal(observer: () => void) { disposalObserver = observer; },
};
}
test('PlatformHandlers commits every room event before one typed game event reads the snapshot', () => {
const setup = makeHandlers();
setup.handlers.dispatch({
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
});
function dispatchWithEvent(
message: { route: string; rpc: string; data: Record<string, unknown> },
expectedType: PlatformToGameEvent['type'],
assertSnapshot: (snapshot: PlatformGameSnapshot) => void,
): void {
const order: string[] = [];
let received = 0;
const unsubscribe = setup.store.subscribe(() => { order.push('store'); });
setup.observeEvent((event, snapshot) => {
received += 1;
order.push('game');
assert.equal(event.type, expectedType);
assertSnapshot(snapshot);
});
assert.equal(setup.handlers.dispatch(message), 'handled-platform');
unsubscribe();
assert.equal(received, 1);
assert.deepEqual(order, ['store', 'game']);
}
const join = fixture('self-join-room.json');
delete join.deskinfo;
join.deskwar = 0;
dispatchWithEvent(
{ route: 'agent', rpc: 'self_join_room', data: join },
'room.entered',
(snapshot) => { assert.equal(snapshot.room.roomcode, '100001'); },
);
dispatchWithEvent(
{
route: 'room',
rpc: 'other_join_room',
data: {
seat: 2, playerid: 77, nickname: '对家', avatar: 'http://b', sex: 1,
ip: '127.0.0.2', onstate: 0, bean: 90, isprepare: 0,
},
},
'room.player-joined',
(snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.playerId, 77); },
);
dispatchWithEvent(
{ route: 'room', rpc: 'player_prepare', data: { seat: 2, deskwar: 1 } },
'room.player-ready',
(snapshot) => {
assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.ready, true);
assert.equal(snapshot.room.stage, 1);
},
);
dispatchWithEvent(
{ route: 'room', rpc: 'other_offline', data: { seat: 2 } },
'room.player-offline',
(snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.online, false); },
);
dispatchWithEvent(
{ route: 'room', rpc: 'other_online', data: { seat: 2, ip: '127.0.0.3' } },
'room.player-online',
(snapshot) => { assert.equal(snapshot.seats.find((seat) => seat.seat === 2)?.online, true); },
);
dispatchWithEvent(
{ route: 'room', rpc: 'other_exit_room', data: { seat: 2 } },
'room.player-left',
(snapshot) => { assert.equal(snapshot.seats.some((seat) => seat.seat === 2), false); },
);
});
test('self_exit_room commits the outside snapshot before disposing the game session', () => {
const setup = makeHandlers();
setup.handlers.dispatch({
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
});
const order: string[] = [];
setup.store.subscribe(() => { order.push('store'); });
setup.observeDisposal(() => {
order.push('dispose');
assert.equal(setup.store.getState().room.kind, 'outside');
});
setup.handlers.dispatch({ route: 'room', rpc: 'self_exit_room', data: { seat: 1 } });
assert.deepEqual(order, ['store', 'dispose']);
assert.equal(setup.gameSession.state, 'idle');
});
test('PlatformHandlers preserves complete switch payloads and builds the exact resend envelopes', () => {
const setup = makeHandlers();
const roomData = { roomserver: '10.0.0.2:3088', epoch: 7 };
const agentData = {
agentserver: 'secure.example:443/path', opt: 'free_room', trace: { id: 'x' },
};
assert.equal(setup.router.dispatch({
route: 'room', rpc: 'connect_roomserver', data: roomData,
}), 'handled-platform');
assert.equal(setup.router.dispatch({
route: 'agent', rpc: 'connect_agentserver', data: agentData,
}), 'handled-platform');
assert.deepEqual(setup.connection.switchCalls, [
{
target: 'ws://10.0.0.2:3088',
resend: { app: 'youle', route: 'room', rpc: 'connect_roomserver', data: roomData },
},
{
target: 'ws://secure.example:443/path',
resend: { app: 'youle', route: 'agent', rpc: 'connect_agentserver', data: agentData },
},
]);
assert.equal(setup.connection.switchCalls[0]?.resend.data, roomData);
assert.equal(setup.connection.switchCalls[1]?.resend.data, agentData);
});
test('PlatformHandlers rejects unknown, route-mismatched, missing, and invalid switch targets without effects', () => {
const setup = makeHandlers();
const before = setup.store.getState();
assert.throws(
() => setup.handlers.dispatch({ route: 'agent', rpc: 'update_bean', data: { bean: 1 } }),
/update_bean/,
);
assert.throws(
() => setup.handlers.dispatch({ route: 'agent', rpc: 'connect_roomserver', data: { roomserver: 'x:1' } }),
/route/i,
);
assert.throws(
() => setup.handlers.dispatch({ route: 'room', rpc: 'connect_roomserver', data: {} }),
/roomserver/,
);
assert.throws(
() => setup.handlers.dispatch({
route: 'room', rpc: 'connect_roomserver', data: { roomserver: 'http://invalid.example' },
}),
/roomserver|WebSocket/i,
);
assert.equal(setup.store.getState(), before);
assert.deepEqual(setup.connection.switchCalls, []);
});
test('kick_server closes the game, marks the runtime session, and stops the connection once', () => {
const setup = makeHandlers();
setup.handlers.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') });
const kickData = { msg: '检查到新版本', code: 1 };
setup.session.markLoginPending();
setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData });
assert.equal(setup.gameSession.state, 'idle');
assert.equal(setup.session.kicked, true);
assert.equal(setup.session.loginPending, false);
assert.equal(setup.getDisposals(), 1);
assert.deepEqual(setup.connection.stopCalls, [kickData]);
assert.equal(setup.connection.stopCalls[0], kickData);
});
@@ -1,75 +1,166 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
import { ActiveGame } from '../../YouleNexus/assets/framework/protocol/active-game.ts';
import { test } from 'node:test';
import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
import type { IGameModule, GameContext } from '../../YouleNexus/assets/framework/sdk/index.ts';
import type {
GameEntry,
GameHost,
GameModule,
PlatformGameSnapshot,
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import { GameSessionHost } from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
class MockGame implements IGameModule {
readonly route = 'mock';
events: string[] = [];
onEnter(_ctx: GameContext): void {}
onExit(): void {}
onReceive(rpc: string, _d: unknown): void { this.events.push(`onReceive:${rpc}`); }
onReconnect(_d: unknown): void {}
class LoginGateFixture {
private pending = false;
get loginPending(): boolean { return this.pending; }
markLoginPending(): void { this.pending = true; }
completeLogin(): void { this.pending = false; }
}
const msg = (route: string, rpc: string, data: unknown = {}): InboundMessage =>
({ route, rpc, data } as InboundMessage);
test('platform route → 平台 handler(已注册)', () => {
const slot = new ActiveGame();
const calls: Array<[string, unknown]> = [];
const router = new Router({
activeGame: slot,
platformHandlers: { player_login: (d) => calls.push(['player_login', d]) },
});
router.dispatch(msg('agent', 'player_login', { state: 0 }));
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'player_login');
});
test('platform route → 平台 handler 未注册 → 显式抛错(第二准则)', () => {
const slot = new ActiveGame();
const router = new Router({ activeGame: slot, platformHandlers: {} });
assert.throws(() => router.dispatch(msg('agent', 'unknown_rpc')), /unknown_rpc/);
});
test('非 platform route 但 activeGame 已注册 → onReceive', () => {
const slot = new ActiveGame();
const game = new MockGame();
slot.set(game);
const router = new Router({ activeGame: slot, platformHandlers: {} });
router.dispatch(msg('mock', 'deal_card', { n: 1 }));
assert.deepEqual(game.events, ['onReceive:deal_card']);
});
test('非 platform route 但 activeGame 未注册 → 显式抛错', () => {
const slot = new ActiveGame();
const router = new Router({ activeGame: slot, platformHandlers: {} });
assert.throws(() => router.dispatch(msg('mock', 'deal_card')), /mock.*deal_card/);
});
test('route 与 activeGame.route 不匹配 → 显式抛错', () => {
const slot = new ActiveGame();
slot.set(new MockGame()); // route = 'mock'
const router = new Router({ activeGame: slot, platformHandlers: {} });
assert.throws(() => router.dispatch(msg('other-game', 'rpc')), /other-game/);
});
test('platform/agent/room 三 route 全部走平台 handler(对称)', () => {
const slot = new ActiveGame();
const calls: string[] = [];
const router = new Router({
activeGame: slot,
platformHandlers: {
r1: () => calls.push('r1'),
r2: () => calls.push('r2'),
r3: () => calls.push('r3'),
function makeGameSession(received: InboundMessage[]): GameSessionHost {
const entry: GameEntry = {
key: 'fixture',
gameId: 41,
route: 'fixture-game',
resolveSeatCount: () => 4,
createModule: (): GameModule => ({
attach() {},
handlePlatformEvent() {},
handleGameMessage(message) {
received.push({ route: entry.route, rpc: message.rpc, data: message.data });
},
restore() {},
dispose() {},
}),
};
const snapshot: PlatformGameSnapshot = {
connection: { phase: 'logged-in' },
self: { playerId: 1, seat: 0 },
room: {
roomcode: '100001', roomtype: [], stage: 0, needprepare: 1, infinite: 0,
},
seats: [],
};
const host: GameHost = {
seat: { toView: (seat) => seat, toServer: (seat) => seat },
getSnapshot: () => snapshot,
subscribe: () => () => {},
sendGameMessage() {},
execute() {},
};
const session = new GameSessionHost(entry);
session.open(host);
return session;
}
test('Router applies the exact login gate before its single dispatch split', () => {
const gate = new LoginGateFixture();
const platformCalls: InboundMessage[] = [];
const gameCalls: InboundMessage[] = [];
const gameSession = makeGameSession(gameCalls);
const router = new Router({
session: gate,
platform: {
dispatch(message: InboundMessage) {
platformCalls.push(message);
if (message.rpc === 'player_login') gate.completeLogin();
return 'handled-platform' as const;
},
},
gameSession,
});
router.dispatch(msg('platform', 'r1'));
router.dispatch(msg('agent', 'r2'));
router.dispatch(msg('room', 'r3'));
assert.deepEqual(calls, ['r1', 'r2', 'r3']);
const updateBeanMessage: InboundMessage = {
route: 'agent', rpc: 'update_bean', data: { bean: 10 },
};
const playerLoginMessage: InboundMessage = {
route: 'agent', rpc: 'player_login', data: { state: 0 },
};
const gameMessage: InboundMessage = {
route: 'fixture-game', rpc: 'deal', data: { card: 7 },
};
gate.markLoginPending();
assert.equal(router.dispatch(updateBeanMessage), 'ignored-login-gate');
assert.equal(router.dispatch(gameMessage), 'ignored-login-gate');
assert.equal(router.dispatch(playerLoginMessage), 'handled-platform');
assert.equal(router.dispatch(gameMessage), 'handled-game');
assert.deepEqual(platformCalls, [playerLoginMessage]);
assert.deepEqual(gameCalls, [gameMessage]);
});
test('Router lets kick_server through the login gate but ignores every other rpc', () => {
const gate = new LoginGateFixture();
const calls: InboundMessage[] = [];
const router = new Router({
session: gate,
platform: {
dispatch(message: InboundMessage) {
calls.push(message);
return 'handled-platform' as const;
},
},
gameSession: makeGameSession([]),
});
const kick: InboundMessage = { route: 'agent', rpc: 'kick_server', data: { msg: 'bye' } };
gate.markLoginPending();
assert.equal(router.dispatch(kick), 'handled-platform');
assert.deepEqual(calls, [kick]);
});
test('Router sends all platform routes to PlatformHandlers exactly once', () => {
const gate = new LoginGateFixture();
const calls: InboundMessage[] = [];
const router = new Router({
session: gate,
platform: {
dispatch(message: InboundMessage) {
calls.push(message);
return 'handled-platform' as const;
},
},
gameSession: makeGameSession([]),
});
const messages: InboundMessage[] = [
{ route: 'platform', rpc: 'p', data: {} },
{ route: 'agent', rpc: 'a', data: {} },
{ route: 'room', rpc: 'r', data: {} },
];
for (const message of messages) {
assert.equal(router.dispatch(message), 'handled-platform');
}
assert.deepEqual(calls, messages);
});
test('Router preserves game payload identity and mismatched routes have no side effects', () => {
const gate = new LoginGateFixture();
const gameCalls: InboundMessage[] = [];
const platformCalls: InboundMessage[] = [];
const router = new Router({
session: gate,
platform: {
dispatch(message: InboundMessage) {
platformCalls.push(message);
return 'handled-platform' as const;
},
},
gameSession: makeGameSession(gameCalls),
});
const data = { cards: [1, 2, 3] };
assert.throws(
() => router.dispatch({ route: 'other-game', rpc: 'deal', data }),
/route.*fixture-game.*other-game|route.*other-game/i,
);
assert.equal(platformCalls.length, 0);
assert.equal(gameCalls.length, 0);
assert.equal(
router.dispatch({ route: 'fixture-game', rpc: 'deal', data }),
'handled-game',
);
assert.equal(gameCalls[0]?.data, data);
});