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' }); });