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 type { InboundMessage } from '../core/types/envelope.ts';
|
||||||
import { Route } from '../core/constants.ts';
|
import type { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
|
||||||
import type { ActiveGame } from './active-game.ts';
|
import type { RuntimeSession } from '../platform/runtime-session.ts';
|
||||||
|
import type { PlatformDispatchResult, PlatformHandlers } from './platform-handlers.ts';
|
||||||
|
|
||||||
/** 平台层 handler 表:key = rpc 字符串(与协议逐字对齐)。 */
|
export type RouterDispatchResult =
|
||||||
export type PlatformHandlers = Record<string, (data: unknown) => void>;
|
| 'ignored-login-gate'
|
||||||
|
| PlatformDispatchResult
|
||||||
|
| 'handled-game';
|
||||||
|
|
||||||
/**
|
export interface RouterOptions {
|
||||||
* Router:收包路由分界(C §4.1 的红线)。
|
readonly session: Pick<RuntimeSession, 'loginPending'>;
|
||||||
*
|
readonly platform: Pick<PlatformHandlers, 'dispatch'>;
|
||||||
* 分发规则:
|
readonly gameSession: Pick<GameSessionHost, 'dispatchGameMessage'>;
|
||||||
* - route ∈ {platform, agent, room} → 平台 handlers(RpcList 覆盖的包)
|
}
|
||||||
* - route = <game route>(= activeGame.route)→ activeGame.onReceive
|
|
||||||
* - 其他或未注册:显式抛错(第二准则:不静默吞包)
|
/** Classifies each inbound business envelope exactly once. */
|
||||||
*
|
|
||||||
* 不持有状态——只做消息分发。状态由 PlatformSession/子游戏维护。
|
|
||||||
*/
|
|
||||||
export class Router {
|
export class Router {
|
||||||
constructor(
|
constructor(private readonly options: RouterOptions) {}
|
||||||
private readonly opts: {
|
|
||||||
activeGame: ActiveGame;
|
|
||||||
platformHandlers: PlatformHandlers;
|
|
||||||
},
|
|
||||||
) {}
|
|
||||||
|
|
||||||
dispatch(msg: InboundMessage): void {
|
dispatch(message: InboundMessage): RouterDispatchResult {
|
||||||
const { route, rpc, data } = msg;
|
if (this.options.session.loginPending && !isLoginGateControl(message)) {
|
||||||
// 平台三层 route:platform/agent/room 全部走平台 handlers
|
return 'ignored-login-gate';
|
||||||
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);
|
if (isPlatformRoute(message.route)) {
|
||||||
return;
|
return this.options.platform.dispatch(message);
|
||||||
}
|
}
|
||||||
// 对局 route:必须与 activeGame.route 严格匹配
|
this.options.gameSession.dispatchGameMessage(message.route, {
|
||||||
const game = this.opts.activeGame.get();
|
rpc: message.rpc,
|
||||||
if (!game) {
|
data: message.data,
|
||||||
throw new Error(
|
});
|
||||||
`Router 收到对局包但无激活子游戏: route=${route} rpc=${rpc}(未进房或子游戏未注册)`,
|
return 'handled-game';
|
||||||
);
|
|
||||||
}
|
|
||||||
if (game.route !== route) {
|
|
||||||
throw new Error(
|
|
||||||
`Router route 与 activeGame.route 不匹配: msg.route=${route} activeGame.route=${game.route}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
game.onReceive(rpc, data);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 { afterEach, test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
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';
|
import { scanImportBoundaries } from '../../scripts/lib/import-boundaries.mjs';
|
||||||
|
|
||||||
const roots = [];
|
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 () => {
|
test('scanner rejects sdk contracts importing a framework platform path', async () => {
|
||||||
const root = await createFixtureRoot();
|
const root = await createFixtureRoot();
|
||||||
await writeFixture(root, 'framework/sdk/contracts/bad.ts', "import '../platform/session.ts'\n");
|
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 assert from 'node:assert/strict';
|
||||||
import { Router } from '../../YouleNexus/assets/framework/protocol/router.ts';
|
import { test } from 'node:test';
|
||||||
import { ActiveGame } from '../../YouleNexus/assets/framework/protocol/active-game.ts';
|
|
||||||
import type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
|
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 {
|
class LoginGateFixture {
|
||||||
readonly route = 'mock';
|
private pending = false;
|
||||||
events: string[] = [];
|
|
||||||
onEnter(_ctx: GameContext): void {}
|
get loginPending(): boolean { return this.pending; }
|
||||||
onExit(): void {}
|
markLoginPending(): void { this.pending = true; }
|
||||||
onReceive(rpc: string, _d: unknown): void { this.events.push(`onReceive:${rpc}`); }
|
completeLogin(): void { this.pending = false; }
|
||||||
onReconnect(_d: unknown): void {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = (route: string, rpc: string, data: unknown = {}): InboundMessage =>
|
function makeGameSession(received: InboundMessage[]): GameSessionHost {
|
||||||
({ route, rpc, data } as InboundMessage);
|
const entry: GameEntry = {
|
||||||
|
key: 'fixture',
|
||||||
test('platform route → 平台 handler(已注册)', () => {
|
gameId: 41,
|
||||||
const slot = new ActiveGame();
|
route: 'fixture-game',
|
||||||
const calls: Array<[string, unknown]> = [];
|
resolveSeatCount: () => 4,
|
||||||
const router = new Router({
|
createModule: (): GameModule => ({
|
||||||
activeGame: slot,
|
attach() {},
|
||||||
platformHandlers: { player_login: (d) => calls.push(['player_login', d]) },
|
handlePlatformEvent() {},
|
||||||
});
|
handleGameMessage(message) {
|
||||||
router.dispatch(msg('agent', 'player_login', { state: 0 }));
|
received.push({ route: entry.route, rpc: message.rpc, data: message.data });
|
||||||
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'),
|
|
||||||
},
|
},
|
||||||
|
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'));
|
const updateBeanMessage: InboundMessage = {
|
||||||
router.dispatch(msg('agent', 'r2'));
|
route: 'agent', rpc: 'update_bean', data: { bean: 10 },
|
||||||
router.dispatch(msg('room', 'r3'));
|
};
|
||||||
assert.deepEqual(calls, ['r1', 'r2', 'r3']);
|
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);
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user