diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts new file mode 100644 index 0000000..dbd5e5f --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/active-game.ts @@ -0,0 +1,17 @@ +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; } +} \ No newline at end of file diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts new file mode 100644 index 0000000..4ddbc62 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/router.ts @@ -0,0 +1,53 @@ +import type { InboundMessage } from '../core/types/envelope.ts'; +import { Route } from '../core/constants.ts'; +import type { ActiveGame } from './active-game.ts'; + +/** 平台层 handler 表:key = rpc 字符串(与协议逐字对齐)。 */ +export type PlatformHandlers = Record void>; + +/** + * Router:收包路由分界(C §4.1 的红线)。 + * + * 分发规则: + * - route ∈ {platform, agent, room} → 平台 handlers(RpcList 覆盖的包) + * - route = (= activeGame.route)→ activeGame.onReceive + * - 其他或未注册:显式抛错(第二准则:不静默吞包) + * + * 不持有状态——只做消息分发。状态由 PlatformSession/子游戏维护。 + */ +export class Router { + constructor( + private readonly opts: { + activeGame: ActiveGame; + platformHandlers: PlatformHandlers; + }, + ) {} + + 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; + } + // 对局 route:必须与 activeGame.route 严格匹配 + const game = this.opts.activeGame.get(); + if (!game) { + throw new Error( + `Router 收到对局包但无激活子游戏: route=${route} rpc=${rpc}(未进房或子游戏未注册)`, + ); + } + if (game.route !== route) { + throw new Error( + `Router route 与 activeGame.route 不匹配: msg.route=${route} activeGame.route=${game.route}`, + ); + } + game.onReceive(rpc, data); + } +} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/protocol/active-game.test.ts b/cocoscreator_projects/framework-tests/protocol/active-game.test.ts new file mode 100644 index 0000000..0886bb8 --- /dev/null +++ b/cocoscreator_projects/framework-tests/protocol/active-game.test.ts @@ -0,0 +1,40 @@ +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); +}); \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/protocol/router.test.ts b/cocoscreator_projects/framework-tests/protocol/router.test.ts new file mode 100644 index 0000000..7c494b1 --- /dev/null +++ b/cocoscreator_projects/framework-tests/protocol/router.test.ts @@ -0,0 +1,75 @@ +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 type { InboundMessage } from '../../YouleNexus/assets/framework/core/types/envelope.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 {} + onExit(): void {} + onReceive(rpc: string, _d: unknown): void { this.events.push(`onReceive:${rpc}`); } + onReconnect(_d: unknown): void {} +} + +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'), + }, + }); + router.dispatch(msg('platform', 'r1')); + router.dispatch(msg('agent', 'r2')); + router.dispatch(msg('room', 'r3')); + assert.deepEqual(calls, ['r1', 'r2', 'r3']); +}); \ No newline at end of file