feat(framework): sdk 子游戏对接边界(IGameModule + GameContext)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<NetClientEvents>;
|
||||
session: PlatformSession;
|
||||
} {
|
||||
const bus = new EventBus<NetClientEvents>();
|
||||
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' });
|
||||
});
|
||||
Reference in New Issue
Block a user