refactor(platform): unify routing and session dispatch
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user