676 lines
24 KiB
TypeScript
676 lines
24 KiB
TypeScript
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,
|
|
RuntimeSessionFault,
|
|
} 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;
|
|
}
|
|
|
|
function caughtError(operation: () => void): Error {
|
|
try {
|
|
operation();
|
|
} catch (error) {
|
|
assert.ok(error instanceof Error);
|
|
return error;
|
|
}
|
|
assert.fail('expected operation to throw');
|
|
}
|
|
|
|
class ConnectionFixture implements ConnectionControlPort {
|
|
readonly switchCalls: Array<{ target: string; resend: OutboundEnvelope }> = [];
|
|
readonly stopCalls: unknown[] = [];
|
|
switchFailure: Error | undefined;
|
|
switchObserver: () => void = () => {};
|
|
stopFailure: Error | undefined;
|
|
stopObserver: () => void = () => {};
|
|
|
|
switchServer(target: string, resend: OutboundEnvelope): void {
|
|
this.switchCalls.push({ target, resend });
|
|
this.switchObserver();
|
|
if (this.switchFailure) throw this.switchFailure;
|
|
}
|
|
|
|
stopForKick(data: unknown): void {
|
|
this.stopCalls.push(data);
|
|
this.stopObserver();
|
|
if (this.stopFailure) throw this.stopFailure;
|
|
}
|
|
}
|
|
|
|
function makeHandlers() {
|
|
const store = new PlatformStore();
|
|
let disposals = 0;
|
|
let eventObserver: (
|
|
event: PlatformToGameEvent,
|
|
snapshot: PlatformGameSnapshot,
|
|
) => void = () => {};
|
|
let disposalObserver: () => void = () => {};
|
|
let invalidationObserver: () => void = () => {};
|
|
let invalidations = 0;
|
|
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 session = new RuntimeSession({
|
|
store,
|
|
gameSession,
|
|
createGameHostLease: () => ({
|
|
host: {
|
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
|
getSnapshot: () => selectGameSnapshot(store.getState()),
|
|
subscribe: () => () => {}, sendGameMessage() {}, execute() {},
|
|
},
|
|
invalidate() {
|
|
invalidations += 1;
|
|
invalidationObserver();
|
|
},
|
|
}),
|
|
});
|
|
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,
|
|
getInvalidations: () => invalidations,
|
|
observeEvent(observer: typeof eventObserver) { eventObserver = observer; },
|
|
observeDisposal(observer: () => void) { disposalObserver = observer; },
|
|
observeInvalidation(observer: () => void) { invalidationObserver = observer; },
|
|
};
|
|
}
|
|
|
|
function makeReentrantRouterFixture(log: string[]) {
|
|
const store = new PlatformStore();
|
|
let nextModuleId = 0;
|
|
const restores: Array<{ moduleId: number; deskinfo: unknown }> = [];
|
|
const entry: GameEntry = {
|
|
key: 'reentrant-fixture',
|
|
gameId: 41,
|
|
route: 'fixture-game',
|
|
resolveSeatCount: () => 4,
|
|
createModule: (): GameModule => {
|
|
const moduleId = ++nextModuleId;
|
|
let host: GameHost | undefined;
|
|
return {
|
|
attach(attachedHost) {
|
|
host = attachedHost;
|
|
log.push(`attach:${moduleId}:${attachedHost.getSnapshot().room.roomcode}`);
|
|
},
|
|
handlePlatformEvent(event) {
|
|
if (!host) throw new Error('fixture module has no host');
|
|
const snapshot = host.getSnapshot();
|
|
if (event.type === 'room.entered') {
|
|
log.push(`event:${moduleId}:room.entered:${snapshot.room.roomcode}`);
|
|
return;
|
|
}
|
|
if (event.type === 'room.player-offline' || event.type === 'room.player-online') {
|
|
const online = snapshot.seats.find((seat) => seat.seat === event.seat)?.online;
|
|
log.push(`event:${moduleId}:${event.type}:${String(online)}`);
|
|
}
|
|
},
|
|
handleGameMessage() {},
|
|
restore(deskinfo) {
|
|
restores.push({ moduleId, deskinfo });
|
|
log.push(`restore:${moduleId}`);
|
|
},
|
|
dispose() { log.push(`dispose:${moduleId}`); },
|
|
};
|
|
},
|
|
};
|
|
const gameSession = new GameSessionHost(entry);
|
|
nextModuleId = 0;
|
|
log.length = 0;
|
|
const session = new RuntimeSession({
|
|
store,
|
|
gameSession,
|
|
createGameHostLease: () => ({
|
|
host: {
|
|
seat: { toView: (seat) => seat, toServer: (seat) => seat },
|
|
getSnapshot: () => selectGameSnapshot(store.getState()),
|
|
subscribe: () => () => {},
|
|
sendGameMessage() {},
|
|
execute() {},
|
|
},
|
|
invalidate() {},
|
|
}),
|
|
});
|
|
const connection = new ConnectionFixture();
|
|
const handlers = new PlatformHandlers({ store, gameSession, session, connection });
|
|
const router = new Router({ session, platform: handlers, gameSession });
|
|
return { store, session, gameSession, router, restores };
|
|
}
|
|
|
|
test('Router drains reentrant room entries FIFO so each module receives its own committed room and deskinfo', () => {
|
|
const log: string[] = [];
|
|
const setup = makeReentrantRouterFixture(log);
|
|
setup.router.dispatch({
|
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json'),
|
|
});
|
|
const roomA = fixture('self-join-room.json');
|
|
const roomB = fixture('self-join-room.json');
|
|
roomA.roomcode = 'room-A';
|
|
roomB.roomcode = 'room-B';
|
|
roomA.deskinfo = { round: 'A' };
|
|
roomB.deskinfo = { round: 'B' };
|
|
let queuedResult: ReturnType<Router['dispatch']> | undefined;
|
|
|
|
setup.store.subscribe((state) => {
|
|
if (state.room.kind === 'inside' && state.room.roomcode === 'room-A') {
|
|
log.push('store:A');
|
|
queuedResult = setup.router.dispatch({
|
|
route: 'agent', rpc: 'self_join_room', data: roomB,
|
|
});
|
|
} else if (state.room.kind === 'inside' && state.room.roomcode === 'room-B') {
|
|
log.push('store:B');
|
|
}
|
|
});
|
|
|
|
const result = setup.router.dispatch({
|
|
route: 'agent', rpc: 'self_join_room', data: roomA,
|
|
});
|
|
|
|
assert.equal(result, 'handled-platform');
|
|
assert.equal(queuedResult, 'handled-platform');
|
|
assert.deepEqual(log, [
|
|
'store:A',
|
|
'attach:1:room-A',
|
|
'event:1:room.entered:room-A',
|
|
'restore:1',
|
|
'store:B',
|
|
'dispose:1',
|
|
'attach:2:room-B',
|
|
'event:2:room.entered:room-B',
|
|
'restore:2',
|
|
]);
|
|
assert.deepEqual(setup.restores, [
|
|
{ moduleId: 1, deskinfo: roomA.deskinfo },
|
|
{ moduleId: 2, deskinfo: roomB.deskinfo },
|
|
]);
|
|
assert.equal(setup.restores[0]?.deskinfo, roomA.deskinfo);
|
|
assert.equal(setup.restores[1]?.deskinfo, roomB.deskinfo);
|
|
});
|
|
|
|
test('Router finishes an offline state-first event before draining a reentrant online message', () => {
|
|
const log: string[] = [];
|
|
const setup = makeReentrantRouterFixture(log);
|
|
setup.router.dispatch({
|
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
|
});
|
|
setup.router.dispatch({
|
|
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,
|
|
},
|
|
});
|
|
log.length = 0;
|
|
let queuedResult: ReturnType<Router['dispatch']> | undefined;
|
|
|
|
setup.store.subscribe((state, previous) => {
|
|
if (state.room.kind !== 'inside' || previous.room.kind !== 'inside') return;
|
|
const wasOffline = previous.room.offlineSeats.includes(2);
|
|
const isOffline = state.room.offlineSeats.includes(2);
|
|
if (!wasOffline && isOffline) {
|
|
log.push('store:offline');
|
|
queuedResult = setup.router.dispatch({
|
|
route: 'room', rpc: 'other_online', data: { seat: 2, ip: '127.0.0.3' },
|
|
});
|
|
} else if (wasOffline && !isOffline) {
|
|
log.push('store:online');
|
|
}
|
|
});
|
|
|
|
const result = setup.router.dispatch({
|
|
route: 'room', rpc: 'other_offline', data: { seat: 2 },
|
|
});
|
|
|
|
assert.equal(result, 'handled-platform');
|
|
assert.equal(queuedResult, 'handled-platform');
|
|
assert.deepEqual(log, [
|
|
'store:offline',
|
|
'event:1:room.player-offline:false',
|
|
'store:online',
|
|
'event:1:room.player-online:true',
|
|
]);
|
|
});
|
|
|
|
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('self_exit_room accepts omitted seat, validates a present seat, and is idempotent outside', () => {
|
|
const setup = makeHandlers();
|
|
setup.router.dispatch({
|
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
|
});
|
|
const inside = setup.store.getState();
|
|
const disposalsBefore = setup.getDisposals();
|
|
|
|
assert.throws(
|
|
() => setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { seat: 0 } }),
|
|
/self_exit_room.*seat.*expected 1.*received 0/i,
|
|
);
|
|
assert.equal(setup.store.getState(), inside);
|
|
assert.equal(setup.gameSession.state, 'active');
|
|
assert.equal(setup.getDisposals(), disposalsBefore);
|
|
|
|
let notifications = 0;
|
|
setup.store.subscribe(() => { notifications += 1; });
|
|
assert.equal(
|
|
setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'done' } }),
|
|
'handled-platform',
|
|
);
|
|
assert.equal(setup.store.getState().room.kind, 'outside');
|
|
assert.equal(setup.gameSession.state, 'idle');
|
|
assert.equal(notifications, 1);
|
|
assert.equal(setup.getDisposals(), disposalsBefore + 1);
|
|
|
|
assert.equal(
|
|
setup.router.dispatch({ route: 'room', rpc: 'self_exit_room', data: { reason: 'duplicate' } }),
|
|
'handled-platform',
|
|
);
|
|
assert.equal(notifications, 1);
|
|
assert.equal(setup.getDisposals(), disposalsBefore + 1);
|
|
});
|
|
|
|
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('unknown and every route-mismatched platform rpc leave all observable state unchanged', () => {
|
|
const setup = makeHandlers();
|
|
setup.router.dispatch({
|
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
|
});
|
|
const before = setup.store.getState();
|
|
let notifications = 0;
|
|
let gameEvents = 0;
|
|
setup.store.subscribe(() => { notifications += 1; });
|
|
setup.observeEvent(() => { gameEvents += 1; });
|
|
const rpcsByRoute = [
|
|
['player_login', 'agent'],
|
|
['self_join_room', 'agent'],
|
|
['player_prepare', 'room'],
|
|
['self_exit_room', 'room'],
|
|
['other_join_room', 'room'],
|
|
['other_exit_room', 'room'],
|
|
['other_offline', 'room'],
|
|
['other_online', 'room'],
|
|
['connect_roomserver', 'room'],
|
|
['connect_agentserver', 'agent'],
|
|
['kick_server', 'agent'],
|
|
] as const;
|
|
|
|
for (const route of ['platform', 'agent', 'room'] as const) {
|
|
assert.throws(
|
|
() => setup.router.dispatch({ route, rpc: 'unknown_rpc', data: {} }),
|
|
/unsupported.*unknown_rpc/i,
|
|
`${route}:unknown_rpc`,
|
|
);
|
|
}
|
|
for (const [rpc, expectedRoute] of rpcsByRoute) {
|
|
for (const route of ['platform', 'agent', 'room'] as const) {
|
|
if (route === expectedRoute) continue;
|
|
assert.throws(
|
|
() => setup.router.dispatch({ route, rpc, data: {} }),
|
|
/route mismatch/i,
|
|
`${route}:${rpc}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
assert.equal(setup.store.getState(), before);
|
|
assert.equal(setup.gameSession.state, 'active');
|
|
assert.equal(setup.session.state, 'operational');
|
|
assert.equal(notifications, 0);
|
|
assert.equal(gameEvents, 0);
|
|
assert.equal(setup.getDisposals(), 0);
|
|
assert.equal(setup.getInvalidations(), 0);
|
|
assert.deepEqual(setup.connection.switchCalls, []);
|
|
assert.deepEqual(setup.connection.stopCalls, []);
|
|
});
|
|
|
|
test('PlatformHandlers validates every switch URL before closing the active game', () => {
|
|
const invalidTargets = [
|
|
'http://invalid.example',
|
|
'ws://user@example.com',
|
|
'ws://example.com/#fragment',
|
|
'ws://example.com/#',
|
|
'ws://example.com#',
|
|
'ws://@example.com',
|
|
'ws://:@example.com',
|
|
'ws:///path',
|
|
'ws://example.com:99999',
|
|
'ws://example.com:abc',
|
|
'ws://',
|
|
];
|
|
|
|
for (const roomserver of invalidTargets) {
|
|
const setup = makeHandlers();
|
|
setup.router.dispatch({
|
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
|
});
|
|
const before = setup.store.getState();
|
|
|
|
assert.throws(
|
|
() => setup.router.dispatch({
|
|
route: 'room', rpc: 'connect_roomserver', data: { roomserver },
|
|
}),
|
|
/roomserver|WebSocket/i,
|
|
roomserver,
|
|
);
|
|
|
|
assert.equal(setup.store.getState(), before, roomserver);
|
|
assert.equal(setup.gameSession.state, 'active', roomserver);
|
|
assert.equal(setup.session.state, 'operational', roomserver);
|
|
assert.equal(setup.getDisposals(), 0, roomserver);
|
|
assert.equal(setup.getInvalidations(), 0, roomserver);
|
|
assert.deepEqual(setup.connection.switchCalls, [], roomserver);
|
|
}
|
|
});
|
|
|
|
test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves full payload identity', () => {
|
|
const setup = makeHandlers();
|
|
const roomData = {
|
|
roomserver: 'WS://EXAMPLE.com:80/path/%23anchor?x=1',
|
|
extra: { trace: 1 },
|
|
};
|
|
const agentData = {
|
|
agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 },
|
|
};
|
|
|
|
setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: roomData });
|
|
setup.router.dispatch({ route: 'agent', rpc: 'connect_agentserver', data: agentData });
|
|
|
|
assert.equal(setup.connection.switchCalls[0]?.target, roomData.roomserver);
|
|
assert.equal(setup.connection.switchCalls[0]?.resend.data, roomData);
|
|
assert.equal(setup.connection.switchCalls[1]?.target, agentData.agentserver);
|
|
assert.equal(setup.connection.switchCalls[1]?.resend.data, agentData);
|
|
assert.equal(
|
|
(setup.connection.switchCalls[1]?.resend.data as typeof agentData).extra,
|
|
agentData.extra,
|
|
);
|
|
});
|
|
|
|
test('PlatformHandlers attempts a validated switch after cleanup errors and aggregates all failures', () => {
|
|
const setup = makeHandlers();
|
|
setup.router.dispatch({
|
|
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
|
|
});
|
|
const data = { roomserver: 'wss://switch.example/socket', epoch: 9 };
|
|
const disposeError = new Error('switch:dispose');
|
|
const invalidateError = new Error('switch:invalidate');
|
|
const switchError = new Error('switch:connection');
|
|
setup.observeDisposal(() => { throw disposeError; });
|
|
setup.observeInvalidation(() => { throw invalidateError; });
|
|
setup.connection.switchFailure = switchError;
|
|
|
|
const error = caughtError(() => setup.router.dispatch({
|
|
route: 'room', rpc: 'connect_roomserver', data,
|
|
}));
|
|
|
|
assert.ok(error instanceof RuntimeSessionFault);
|
|
assert.deepEqual(error.errors, [disposeError, invalidateError, switchError]);
|
|
assert.equal(setup.gameSession.state, 'idle');
|
|
assert.equal(setup.session.state, 'faulted');
|
|
assert.equal(setup.getDisposals(), 1);
|
|
assert.equal(setup.getInvalidations(), 1);
|
|
assert.equal(setup.connection.switchCalls.length, 1);
|
|
assert.equal(setup.connection.switchCalls[0]?.target, data.roomserver);
|
|
assert.equal(setup.connection.switchCalls[0]?.resend.data, data);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test('kick_server latches before cleanup and makes a reentrant duplicate a no-op', () => {
|
|
const setup = makeHandlers();
|
|
setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') });
|
|
const kickData = { msg: 'same-reference' };
|
|
let reentrantResult: ReturnType<Router['dispatch']> | undefined;
|
|
|
|
setup.session.markLoginPending();
|
|
setup.observeDisposal(() => {
|
|
assert.equal(setup.session.kicked, true);
|
|
assert.equal(setup.session.loginPending, false);
|
|
reentrantResult = setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData });
|
|
});
|
|
|
|
assert.equal(
|
|
setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }),
|
|
'handled-platform',
|
|
);
|
|
assert.equal(reentrantResult, 'handled-platform');
|
|
assert.equal(setup.getDisposals(), 1);
|
|
assert.equal(setup.getInvalidations(), 1);
|
|
assert.equal(setup.connection.stopCalls.length, 1);
|
|
assert.equal(setup.connection.stopCalls[0], kickData);
|
|
assert.equal(setup.session.state, 'kicked');
|
|
});
|
|
|
|
test('kick_server attempts dispose, invalidate, mark, and stop once then aggregates failures', () => {
|
|
const setup = makeHandlers();
|
|
setup.router.dispatch({ route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json') });
|
|
const kickData = { msg: 'cleanup-failures' };
|
|
const disposeError = new Error('kick:dispose');
|
|
const invalidateError = new Error('kick:invalidate');
|
|
const stopError = new Error('kick:stop');
|
|
setup.observeDisposal(() => { throw disposeError; });
|
|
setup.observeInvalidation(() => { throw invalidateError; });
|
|
setup.connection.stopFailure = stopError;
|
|
setup.session.markLoginPending();
|
|
|
|
const error = caughtError(
|
|
() => setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }),
|
|
);
|
|
|
|
assert.ok(error instanceof RuntimeSessionFault);
|
|
assert.deepEqual(error.errors, [disposeError, invalidateError, stopError]);
|
|
assert.equal(setup.gameSession.state, 'idle');
|
|
assert.equal(setup.session.kicked, true);
|
|
assert.equal(setup.session.state, 'kicked');
|
|
assert.equal(setup.session.loginPending, false);
|
|
assert.equal(setup.getDisposals(), 1);
|
|
assert.equal(setup.getInvalidations(), 1);
|
|
assert.equal(setup.connection.stopCalls.length, 1);
|
|
assert.equal(setup.connection.stopCalls[0], kickData);
|
|
|
|
assert.equal(
|
|
setup.router.dispatch({ route: 'agent', rpc: 'kick_server', data: kickData }),
|
|
'handled-platform',
|
|
);
|
|
assert.equal(setup.getDisposals(), 1);
|
|
assert.equal(setup.getInvalidations(), 1);
|
|
assert.equal(setup.connection.stopCalls.length, 1);
|
|
});
|