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');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user