From d10c43e6eab6714c90fbb03a0825f49d392a0f8c Mon Sep 17 00:00:00 2001 From: Joywayer Date: Mon, 31 Aug 2026 19:50:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(framework):=20sdk=20=E5=AD=90=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E5=AF=B9=E6=8E=A5=E8=BE=B9=E7=95=8C(IGameModule=20+?= =?UTF-8?q?=20GameContext)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GameContext(子游戏调用框架的唯一入口): - 受限 net.send:子游戏不能直接拿到 transport/ws_tcp/start - 只读 PlayerStore/RoomStore/AppStore(state 只读) - seat.toView/fromView:替代旧 ChangeToStatus - events: 子游戏自定义事件总线 IGameModule(子游戏实现,被框架调用): - route: 本游戏的 game route(框架 Router 据此分发对局包) - onEnter/onExit: 进入/离开牌桌 - onReceive(rpc, data): 接收对局包(框架已按 route 过滤) - onReconnect(deskinfo): login 含 deskinfo 时触发(平台层不解析) - serialize?(): 对局快照(断线重连用) - 平台钩子 onPlayerJoin/onPlayerLeave/onReady/onDissolve/onOffline 默认空实现 架构零耦合规则 §3 保证:子游戏只能 import sdk + core 类型, 无法触碰 platform/net 内部实现。 118/118 tests pass, typecheck exit 0。 Co-Authored-By: Claude Opus 5 (1M context) --- .../YouleNexus/assets/framework/sdk/index.ts | 95 +++++++++++++ .../framework-tests/sdk/sdk.test.ts | 132 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts create mode 100644 cocoscreator_projects/framework-tests/sdk/sdk.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts b/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts new file mode 100644 index 0000000..1535a92 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/sdk/index.ts @@ -0,0 +1,95 @@ +import type { EventBus } from '../core/events.ts'; +import type { ReadonlyReactive } from '../core/reactive.ts'; +import type { ReadonlyPlayerStore } from '../platform/readonly.ts'; +import type { ReadonlyRoomStore } from '../platform/readonly.ts'; +import type { ReadonlyAppStore } from '../platform/readonly.ts'; +import type { PlayerState, RoomState, AppState } from '../platform/stores/types.ts'; + +/** + * 受限的发包通道(架构零耦合规则 §3)。 + * + * 子游戏只能通过这个接口发包,**不能**直接拿到 transport/ws/start 等内部能力。 + * 实现由 Router 在创建 GameContext 时注入(生产环境转发到 NetClient)。 + */ +export interface GameNet { + send(route: string, rpc: string, data: unknown): void; +} + +/** + * 座位↔视图工具(C §3 旧 ChangeToStatus 替代)。 + */ +export interface GameSeat { + /** 绝对座位 → 视图位(自己=0,其余环形顺延)。 */ + toView(mySeat: number, targetSeat: number, seatCount: number): number; + /** 视图位 → 绝对座位。 */ + fromView(mySeat: number, viewSeat: number, seatCount: number): number; +} + +/** + * GameContext:子游戏调用框架能力的唯一入口。 + * + * 只暴露只读 Store + 受限 net.send + 座位工具 + 事件总线。 + * 子游戏**不能**通过本接口反改 Store;Store 的修改由平台层负责(第二准则:单一来源)。 + */ +export interface GameContext { + /** 受限发包。 */ + readonly net: GameNet; + /** 只读 PlayerStore。 */ + readonly player: ReadonlyPlayerStore & { readonly state: ReadonlyReactive }; + /** 只读 RoomStore(含 deskinfo 透传引用)。 */ + readonly room: ReadonlyRoomStore & { readonly state: ReadonlyReactive }; + /** 只读 AppStore(连接相位 + 身份)。 */ + readonly app: ReadonlyAppStore & { readonly state: ReadonlyReactive }; + /** 座位↔视图工具。 */ + readonly seat: GameSeat; + /** 事件总线(子游戏可发自定义事件供自己订阅,框架不消费)。 */ + readonly events: EventBus>; +} + +/** + * IGameModule:子游戏实现,被框架调用(框架 spec §4)。 + * + * 子游戏实现这个接口并注册自己;框架 Router 据 route 分发对局包。 + * 子游戏通过 GameContext 调用框架能力(不可 import platform/net 内部)。 + */ +export interface IGameModule { + /** 本游戏的 game route(与 protocol/routes.ts 的 Route 同名)。 */ + readonly route: string; + + /** 进入牌桌场景,框架传入 GameContext。 */ + onEnter(ctx: GameContext): void; + + /** 离开牌桌场景。 */ + onExit(): void; + + /** + * 接收对局包(route = 本游戏 game route 的包)。 + * 框架 router 已按 route 过滤;这里只按 rpc 分发。 + */ + onReceive(rpc: string, data: unknown): void; + + /** + * 登录回包带 deskinfo 时触发(C 规范 §5 边界判据 2:deskinfo 平台层不解析)。 + * 子游戏负责反序列化对局态。 + */ + onReconnect(deskinfo: unknown): void; + + /** + * 可选:返回对局快照。框架在断线重连时收集 → 走 server player_login 响应 deskinfo。 + * 若不实现则重连后从服务器拉取(默认行为)。 + */ + serialize?(): unknown; + + // ---- 平台事件钩子(全部可选,默认空实现)---- + + /** 玩家加入房间(seat = 对方座位)。 */ + onPlayerJoin?(seat: number): void; + /** 玩家离开房间。 */ + onPlayerLeave?(seat: number): void; + /** 玩家准备。 */ + onReady?(seat: number): void; + /** 房间解散。 */ + onDissolve?(): void; + /** 玩家离线/上线(在线由 onPlayerJoin 重复触发,离线由本钩子)。 */ + onOffline?(seat: number): void; +} \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/sdk/sdk.test.ts b/cocoscreator_projects/framework-tests/sdk/sdk.test.ts new file mode 100644 index 0000000..0a0e4a0 --- /dev/null +++ b/cocoscreator_projects/framework-tests/sdk/sdk.test.ts @@ -0,0 +1,132 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventBus } from '../../YouleNexus/assets/framework/core/events.ts'; +import { toView, fromView } from '../../YouleNexus/assets/framework/core/seat.ts'; +import { PlatformSession } from '../../YouleNexus/assets/framework/platform/session.ts'; +import type { NetClientEvents } from '../../YouleNexus/assets/framework/net/net-client.ts'; +import type { GameContext, IGameModule } from '../../YouleNexus/assets/framework/sdk/index.ts'; + +/** + * 真实子游戏端到端模拟:创建 PlatformSession + 注册 Mock 子游戏 + 注入 GameContext。 + * 验证契约边界(IGameModule 钩子触发、GameContext 只读、send 受限)。 + */ + +function makeContext(): { + ctx: GameContext; + sent: Array<{ route: string; rpc: string; data: any }>; + bus: EventBus; + session: PlatformSession; +} { + const bus = new EventBus(); + const session = new PlatformSession(bus); + + // 受限的 send:只记录,不动 NetClient transport(架构零耦合规则) + const sent: Array<{ route: string; rpc: string; data: any }> = []; + const net = { + send(route: string, rpc: string, data: unknown) { sent.push({ route, rpc, data }); }, + }; + + const ctx: GameContext = { + net, + room: session.room, + player: session.player, + app: session.app, + seat: { toView, fromView }, + events: new EventBus(), + }; + + return { ctx, sent, bus, session }; +} + +/** Mock 子游戏:捕获所有钩子调用。 */ +class MockGame implements IGameModule { + readonly route = 'mock-game'; + events: string[] = []; + + onEnter(_ctx: GameContext): void { this.events.push('onEnter'); } + onExit(): void { this.events.push('onExit'); } + onReceive(rpc: string, _data: unknown): void { this.events.push(`onReceive:${rpc}`); } + onReconnect(deskinfo: unknown): void { this.events.push('onReconnect'); void deskinfo; } + serialize(): unknown { return { mock: 'snapshot' }; } + + // 默认空钩子测试 + onPlayerJoin(seat: number): void { this.events.push(`onPlayerJoin:${seat}`); } + onPlayerLeave(seat: number): void { this.events.push(`onPlayerLeave:${seat}`); } + onReady(seat: number): void { this.events.push(`onReady:${seat}`); } + onDissolve(): void { this.events.push('onDissolve'); } + onOffline(seat: number): void { this.events.push(`onOffline:${seat}`); } +} + +test('IGameModule 契约: 注册后 onEnter 触发', () => { + const { ctx } = makeContext(); + const game = new MockGame(); + game.onEnter(ctx); + assert.deepEqual(game.events, ['onEnter']); +}); + +test('IGameModule 契约: onReceive 接收 rpc + data', () => { + const { ctx } = makeContext(); + const game = new MockGame(); + game.onEnter(ctx); + game.onReceive('game_action', { x: 1 }); + assert.deepEqual(game.events, ['onEnter', 'onReceive:game_action']); +}); + +test('IGameModule 契约: 平台钩子默认空实现不抛错(可选)', () => { + // 一个只实现必需钩子的子游戏 + const minimal: IGameModule = { + route: 'minimal', + onEnter() { /* noop */ }, + onExit() { /* noop */ }, + onReceive(_rpc, _data) { /* noop */ }, + onReconnect(_d) { /* noop */ }, + // 钩子全部可选 + }; + // 编译通过 + 不抛错即通过 + minimal.onPlayerJoin?.(1); + minimal.onPlayerLeave?.(2); + minimal.onReady?.(3); + minimal.onDissolve?.(); + minimal.onOffline?.(4); +}); + +test('GameContext: 只暴露 ReadonlyPlayerStore 接口,不暴露 applyLogin', () => { + const { ctx } = makeContext(); + // type 层面:ctx.player 是 ReadonlyPlayerStore 接口,没有 applyLogin + // 这里用类型断言模拟「如果按 ReadonlyPlayerStore 类型使用,applyLogin 应不存在」 + type ReadonlyView = { readonly state: { readonly value: { playerid: number } } }; + const view = ctx.player as unknown as ReadonlyView; + assert.equal(view.state.value.playerid, 0); + // 通过 keyof 验证:ReadonlyPlayerStore 接口暴露的方法只有 state getter + type ExposedKeys = keyof typeof ctx.player; + assert.ok('state' in ctx.player, 'state 是公开字段'); +}); + +test('GameContext.net.send: 受限接口,不暴露 transport / ws_tcp / start', () => { + const { ctx, sent } = makeContext(); + ctx.net.send('agent', 'join_table', { roomcode: 'R1' }); + assert.deepEqual(sent, [{ route: 'agent', rpc: 'join_table', data: { roomcode: 'R1' } }]); + // 不应有 transport / ws_tcp / start + assert.equal((ctx.net as any).transport, undefined); + assert.equal((ctx.net as any).start, undefined); +}); + +test('GameContext.seat.toView: 替代旧 ChangeToStatus', () => { + const { ctx } = makeContext(); + // 旧 ChangeToStatus 语义(已存在 core/seat.ts): 自己=0,其余环形顺延 + assert.equal(ctx.seat.toView(0, 0, 4), 0); // 自己=自己 + assert.equal(ctx.seat.toView(0, 1, 4), 1); // 右手位 + assert.equal(ctx.seat.toView(0, 3, 4), 3); // 左手位 + // 跨玩家视角:玩家 2 看玩家 0,2 应当是 2(view seat),0 应当是 -2 mod 4 = 2(实际为反向) + assert.equal(ctx.seat.fromView(2, 0, 4), 2); // 验证 fromView 反运算 +}); + +test('IGameModule.route 必须等于自己的 game route(框架 Router 据此分发)', () => { + const game = new MockGame(); + assert.equal(game.route, 'mock-game'); +}); + +test('serialize 返回对局快照(断线重连用)', () => { + const game = new MockGame(); + assert.deepEqual(game.serialize(), { mock: 'snapshot' }); +}); \ No newline at end of file