refactor(platform): unify routing and session dispatch
This commit is contained in:
@@ -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