feat(protocol): define strict first-slice wire contracts
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
export {
|
||||||
|
hasOwn,
|
||||||
|
optionalField,
|
||||||
|
requireArray,
|
||||||
|
requireInteger,
|
||||||
|
requireNumber,
|
||||||
|
requireRecord,
|
||||||
|
requireString,
|
||||||
|
type JsonRecord,
|
||||||
|
} from './validation.ts';
|
||||||
|
export {
|
||||||
|
buildLoginRequest,
|
||||||
|
parseLoginResponse,
|
||||||
|
type DeviceLoginMode,
|
||||||
|
type FirstSliceRuntimeConfig,
|
||||||
|
type LoginAccountIdentity,
|
||||||
|
type LoginDeviceSnapshot,
|
||||||
|
type LoginPlayerIdMode,
|
||||||
|
type LoginRequestPayload,
|
||||||
|
type ParsedLoginResponse,
|
||||||
|
} from './login-contract.ts';
|
||||||
|
export {
|
||||||
|
buildExitRoomRequest,
|
||||||
|
buildJoinRoomRequest,
|
||||||
|
buildPrepareRequest,
|
||||||
|
parseConnectAgentserverPayload,
|
||||||
|
parseConnectRoomserverPayload,
|
||||||
|
parseKickServerPayload,
|
||||||
|
parseOtherExitRoomPayload,
|
||||||
|
parseOtherJoinRoomPayload,
|
||||||
|
parseOtherOfflinePayload,
|
||||||
|
parseOtherOnlinePayload,
|
||||||
|
parsePlayerPreparePayload,
|
||||||
|
parseSelfJoinRoomResponse,
|
||||||
|
type JoinRoomEnvironment,
|
||||||
|
type JoinRoomPayload,
|
||||||
|
type ParsedAgentserverPayload,
|
||||||
|
type ParsedKickPayload,
|
||||||
|
type ParsedOnlinePayload,
|
||||||
|
type ParsedRoomEntry,
|
||||||
|
type ParsedRoomResponse,
|
||||||
|
type ParsedRoomserverPayload,
|
||||||
|
type ParsedRoomSnapshot,
|
||||||
|
type ParsedSeatPayload,
|
||||||
|
type ReconnectSnapshot,
|
||||||
|
type RoomActionPayload,
|
||||||
|
type RoomIdentity,
|
||||||
|
} from './room-contracts.ts';
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
buildFirstSliceEnvelope,
|
||||||
|
type FirstSliceEnvelope,
|
||||||
|
} from '../first-slice-routes.ts';
|
||||||
|
import {
|
||||||
|
hasOwn,
|
||||||
|
optionalField,
|
||||||
|
requireInteger,
|
||||||
|
requireNumber,
|
||||||
|
requireRecord,
|
||||||
|
requireString,
|
||||||
|
type JsonRecord,
|
||||||
|
} from './validation.ts';
|
||||||
|
import {
|
||||||
|
parseRoomEntryFields,
|
||||||
|
reconnectFrom,
|
||||||
|
type ParsedRoomSnapshot,
|
||||||
|
type ReconnectSnapshot,
|
||||||
|
} from './room-contracts.ts';
|
||||||
|
|
||||||
|
export interface FirstSliceRuntimeConfig {
|
||||||
|
readonly identity: {
|
||||||
|
readonly agentid: string | number;
|
||||||
|
readonly gameid: string | number;
|
||||||
|
readonly version: number;
|
||||||
|
readonly channelid: string | number;
|
||||||
|
readonly marketid: string | number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginAccountIdentity {
|
||||||
|
readonly openid: string;
|
||||||
|
readonly nickname: string;
|
||||||
|
readonly avatar: string;
|
||||||
|
readonly sex: number;
|
||||||
|
readonly province: string;
|
||||||
|
readonly city: string;
|
||||||
|
readonly unionid: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeviceLoginMode =
|
||||||
|
| { readonly enabled: false }
|
||||||
|
| { readonly enabled: true; readonly telphone: string };
|
||||||
|
|
||||||
|
export type LoginPlayerIdMode =
|
||||||
|
| { readonly enabled: false }
|
||||||
|
| { readonly enabled: true; readonly cachedPlayerId: unknown };
|
||||||
|
|
||||||
|
export interface LoginDeviceSnapshot {
|
||||||
|
readonly ip?: string;
|
||||||
|
readonly location: unknown;
|
||||||
|
readonly machineid: string;
|
||||||
|
readonly machineroom: string;
|
||||||
|
readonly deviceLogin: DeviceLoginMode;
|
||||||
|
readonly loginPlayerId: LoginPlayerIdMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequestPayload extends Readonly<Record<string, unknown>> {
|
||||||
|
readonly agentid: string | number;
|
||||||
|
readonly gameid: string | number;
|
||||||
|
readonly openid: string;
|
||||||
|
readonly nickname: string;
|
||||||
|
readonly avatar: string;
|
||||||
|
readonly sex: number;
|
||||||
|
readonly province: string;
|
||||||
|
readonly city: string;
|
||||||
|
readonly unionid: string | number;
|
||||||
|
readonly version: number;
|
||||||
|
readonly channelid: string | number;
|
||||||
|
readonly marketid: string | number;
|
||||||
|
readonly location: unknown;
|
||||||
|
readonly machineid: string;
|
||||||
|
readonly machineroom: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireStringOrNumber(value: unknown, path: string): string | number {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
return requireNumber(value, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBoolean(value: unknown, path: string): boolean {
|
||||||
|
if (typeof value !== 'boolean') {
|
||||||
|
throw new TypeError(`${path}: expected boolean, received ${value === null ? 'null' : typeof value}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLoginRequest(
|
||||||
|
runtimeConfig: FirstSliceRuntimeConfig,
|
||||||
|
account: LoginAccountIdentity,
|
||||||
|
device: LoginDeviceSnapshot,
|
||||||
|
): FirstSliceEnvelope<'player_login', LoginRequestPayload> {
|
||||||
|
const identity = requireRecord(runtimeConfig?.identity, '$.runtimeConfig.identity');
|
||||||
|
const accountRecord = requireRecord(account, '$.account');
|
||||||
|
const deviceRecord = requireRecord(device, '$.device');
|
||||||
|
const location = hasOwn(deviceRecord, 'location') && deviceRecord.location !== undefined
|
||||||
|
? deviceRecord.location
|
||||||
|
: (() => { throw new TypeError('$.device.location: expected present field, received undefined'); })();
|
||||||
|
if (location !== null) requireRecord(location, '$.device.location');
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
agentid: requireStringOrNumber(identity.agentid, '$.runtimeConfig.identity.agentid'),
|
||||||
|
gameid: requireStringOrNumber(identity.gameid, '$.runtimeConfig.identity.gameid'),
|
||||||
|
openid: requireString(accountRecord.openid, '$.account.openid'),
|
||||||
|
nickname: requireString(accountRecord.nickname, '$.account.nickname'),
|
||||||
|
avatar: requireString(accountRecord.avatar, '$.account.avatar'),
|
||||||
|
sex: requireInteger(accountRecord.sex, '$.account.sex'),
|
||||||
|
province: requireString(accountRecord.province, '$.account.province'),
|
||||||
|
city: requireString(accountRecord.city, '$.account.city'),
|
||||||
|
unionid: requireStringOrNumber(accountRecord.unionid, '$.account.unionid'),
|
||||||
|
version: requireNumber(identity.version, '$.runtimeConfig.identity.version'),
|
||||||
|
channelid: requireStringOrNumber(identity.channelid, '$.runtimeConfig.identity.channelid'),
|
||||||
|
marketid: requireStringOrNumber(identity.marketid, '$.runtimeConfig.identity.marketid'),
|
||||||
|
location,
|
||||||
|
machineid: requireString(deviceRecord.machineid, '$.device.machineid'),
|
||||||
|
machineroom: requireString(deviceRecord.machineroom, '$.device.machineroom'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ip = optionalField(deviceRecord, 'ip', '$.device', requireString);
|
||||||
|
if (ip !== undefined) data.ip = ip;
|
||||||
|
|
||||||
|
const deviceLogin = requireRecord(deviceRecord.deviceLogin, '$.device.deviceLogin');
|
||||||
|
if (requireBoolean(deviceLogin.enabled, '$.device.deviceLogin.enabled')) {
|
||||||
|
data.telphone = requireString(deviceLogin.telphone, '$.device.deviceLogin.telphone');
|
||||||
|
data.telphoneAuto = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginPlayerId = requireRecord(deviceRecord.loginPlayerId, '$.device.loginPlayerId');
|
||||||
|
if (requireBoolean(loginPlayerId.enabled, '$.device.loginPlayerId.enabled')) {
|
||||||
|
const cached = loginPlayerId.cachedPlayerId;
|
||||||
|
if (typeof cached === 'number' && Number.isInteger(cached) && cached > 0) {
|
||||||
|
data.playerid = cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildFirstSliceEnvelope('player_login', data as LoginRequestPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedLoginResponse {
|
||||||
|
readonly state: number;
|
||||||
|
readonly playerid?: number;
|
||||||
|
readonly room: ParsedRoomSnapshot | null;
|
||||||
|
readonly reconnect: ReconnectSnapshot;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLoginResponse(value: unknown): ParsedLoginResponse {
|
||||||
|
const raw = requireRecord(value, '$.data');
|
||||||
|
const state = requireInteger(raw.state, '$.data.state');
|
||||||
|
const playerid = state === 0
|
||||||
|
? requireInteger(raw.playerid, '$.data.playerid')
|
||||||
|
: optionalField(raw, 'playerid', '$.data', requireInteger);
|
||||||
|
const hasRoomFields = ['roomcode', 'seat', 'roomtype', 'players']
|
||||||
|
.some((field) => hasOwn(raw, field));
|
||||||
|
const room = hasRoomFields ? parseRoomEntryFields(raw) : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
...(playerid === undefined ? {} : { playerid }),
|
||||||
|
room,
|
||||||
|
reconnect: reconnectFrom(raw),
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
buildFirstSliceEnvelope,
|
||||||
|
type FirstSliceEnvelope,
|
||||||
|
} from '../first-slice-routes.ts';
|
||||||
|
import {
|
||||||
|
hasOwn,
|
||||||
|
requireArray,
|
||||||
|
requireInteger,
|
||||||
|
requireRecord,
|
||||||
|
requireString,
|
||||||
|
type JsonRecord,
|
||||||
|
} from './validation.ts';
|
||||||
|
import type { FirstSliceRuntimeConfig } from './login-contract.ts';
|
||||||
|
|
||||||
|
export type ReconnectSnapshot =
|
||||||
|
| { readonly present: false }
|
||||||
|
| { readonly present: true; readonly value: unknown };
|
||||||
|
|
||||||
|
export interface ParsedRoomSnapshot {
|
||||||
|
readonly roomcode: string;
|
||||||
|
readonly seat: number;
|
||||||
|
readonly roomtype: readonly unknown[];
|
||||||
|
readonly players: readonly unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRoomEntry {
|
||||||
|
readonly room: ParsedRoomSnapshot;
|
||||||
|
readonly reconnect: ReconnectSnapshot;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconnectFrom(raw: JsonRecord): ReconnectSnapshot {
|
||||||
|
if (!Boolean(raw.deskinfo)) return { present: false };
|
||||||
|
return { present: true, value: raw.deskinfo };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRoomEntryFields(raw: JsonRecord): ParsedRoomSnapshot {
|
||||||
|
const roomcode = requireString(raw.roomcode, '$.data.roomcode');
|
||||||
|
const seat = requireInteger(raw.seat, '$.data.seat');
|
||||||
|
const roomtype = requireArray(raw.roomtype, '$.data.roomtype');
|
||||||
|
const players = requireArray(raw.players, '$.data.players');
|
||||||
|
if (seat < 0 || seat >= players.length) {
|
||||||
|
throw new RangeError(`$.data.seat: expected server seat index within players, received number`);
|
||||||
|
}
|
||||||
|
return { roomcode, seat, roomtype, players };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomIdentity {
|
||||||
|
readonly playerid: number;
|
||||||
|
readonly roomcode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomActionPayload extends Readonly<Record<string, unknown>> {
|
||||||
|
readonly agentid: string | number;
|
||||||
|
readonly playerid: number;
|
||||||
|
readonly gameid: string | number;
|
||||||
|
readonly roomcode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseRoomAction(
|
||||||
|
runtimeConfig: FirstSliceRuntimeConfig,
|
||||||
|
room: RoomIdentity,
|
||||||
|
): RoomActionPayload {
|
||||||
|
const identity = requireRecord(runtimeConfig?.identity, '$.runtimeConfig.identity');
|
||||||
|
const roomRecord = requireRecord(room, '$.room');
|
||||||
|
const agentid = identity.agentid;
|
||||||
|
const gameid = identity.gameid;
|
||||||
|
if (typeof agentid !== 'string' && typeof agentid !== 'number') {
|
||||||
|
throw new TypeError(`$.runtimeConfig.identity.agentid: expected string or number, received ${typeof agentid}`);
|
||||||
|
}
|
||||||
|
if (typeof gameid !== 'string' && typeof gameid !== 'number') {
|
||||||
|
throw new TypeError(`$.runtimeConfig.identity.gameid: expected string or number, received ${typeof gameid}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
agentid,
|
||||||
|
playerid: requireInteger(roomRecord.playerid, '$.room.playerid'),
|
||||||
|
gameid,
|
||||||
|
roomcode: requireString(roomRecord.roomcode, '$.room.roomcode'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPrepareRequest(
|
||||||
|
runtimeConfig: FirstSliceRuntimeConfig,
|
||||||
|
room: RoomIdentity,
|
||||||
|
): FirstSliceEnvelope<'player_prepare', RoomActionPayload> {
|
||||||
|
return buildFirstSliceEnvelope('player_prepare', baseRoomAction(runtimeConfig, room));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExitRoomRequest(
|
||||||
|
runtimeConfig: FirstSliceRuntimeConfig,
|
||||||
|
room: RoomIdentity,
|
||||||
|
): FirstSliceEnvelope<'self_exit_room', RoomActionPayload> {
|
||||||
|
return buildFirstSliceEnvelope('self_exit_room', baseRoomAction(runtimeConfig, room));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinRoomEnvironment {
|
||||||
|
readonly location: unknown;
|
||||||
|
readonly ip: string;
|
||||||
|
readonly vipMatch?: 1;
|
||||||
|
readonly match_id?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinRoomPayload extends RoomActionPayload {
|
||||||
|
readonly location: unknown;
|
||||||
|
readonly ip: string;
|
||||||
|
readonly vipMatch?: 1;
|
||||||
|
readonly match_id?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildJoinRoomRequest(
|
||||||
|
runtimeConfig: FirstSliceRuntimeConfig,
|
||||||
|
room: RoomIdentity,
|
||||||
|
environment: JoinRoomEnvironment,
|
||||||
|
): FirstSliceEnvelope<'self_join_room', JoinRoomPayload> {
|
||||||
|
const environmentRecord = requireRecord(environment, '$.environment');
|
||||||
|
if (!hasOwn(environmentRecord, 'location') || environmentRecord.location === undefined) {
|
||||||
|
throw new TypeError('$.environment.location: expected present field, received undefined');
|
||||||
|
}
|
||||||
|
if (environmentRecord.location !== null) {
|
||||||
|
requireRecord(environmentRecord.location, '$.environment.location');
|
||||||
|
}
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
...baseRoomAction(runtimeConfig, room),
|
||||||
|
location: environmentRecord.location,
|
||||||
|
ip: requireString(environmentRecord.ip, '$.environment.ip'),
|
||||||
|
};
|
||||||
|
if (hasOwn(environmentRecord, 'vipMatch')) data.vipMatch = environmentRecord.vipMatch;
|
||||||
|
if (hasOwn(environmentRecord, 'match_id')) data.match_id = environmentRecord.match_id;
|
||||||
|
return buildFirstSliceEnvelope('self_join_room', data as JoinRoomPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRoomResponse {
|
||||||
|
readonly state: number;
|
||||||
|
readonly room: ParsedRoomSnapshot | null;
|
||||||
|
readonly reconnect: ReconnectSnapshot;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSelfJoinRoomResponse(value: unknown): ParsedRoomResponse {
|
||||||
|
const raw = requireRecord(value, '$.data');
|
||||||
|
const state = requireInteger(raw.state, '$.data.state');
|
||||||
|
const room = state === 0 ? parseRoomEntryFields(raw) : null;
|
||||||
|
return { state, room, reconnect: reconnectFrom(raw), raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSeatPayload {
|
||||||
|
readonly seat: number;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSeatPayload(value: unknown): ParsedSeatPayload {
|
||||||
|
const raw = requireRecord(value, '$.data');
|
||||||
|
const seat = requireInteger(raw.seat, '$.data.seat');
|
||||||
|
if (seat < 0) throw new RangeError('$.data.seat: expected non-negative server seat, received number');
|
||||||
|
return { seat, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parsePlayerPreparePayload = parseSeatPayload;
|
||||||
|
export const parseOtherJoinRoomPayload = parseSeatPayload;
|
||||||
|
export const parseOtherExitRoomPayload = parseSeatPayload;
|
||||||
|
export const parseOtherOfflinePayload = parseSeatPayload;
|
||||||
|
|
||||||
|
export interface ParsedOnlinePayload extends ParsedSeatPayload {
|
||||||
|
readonly ip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOtherOnlinePayload(value: unknown): ParsedOnlinePayload {
|
||||||
|
const parsed = parseSeatPayload(value);
|
||||||
|
return { seat: parsed.seat, ip: requireString(parsed.raw.ip, '$.data.ip'), raw: parsed.raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRoomserverPayload {
|
||||||
|
readonly roomserver: string;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConnectRoomserverPayload(value: unknown): ParsedRoomserverPayload {
|
||||||
|
const raw = requireRecord(value, '$.data');
|
||||||
|
return { roomserver: requireString(raw.roomserver, '$.data.roomserver'), raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedAgentserverPayload {
|
||||||
|
readonly agentserver: string;
|
||||||
|
readonly opt: string;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseConnectAgentserverPayload(value: unknown): ParsedAgentserverPayload {
|
||||||
|
const raw = requireRecord(value, '$.data');
|
||||||
|
return {
|
||||||
|
agentserver: requireString(raw.agentserver, '$.data.agentserver'),
|
||||||
|
opt: requireString(raw.opt, '$.data.opt'),
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedKickPayload {
|
||||||
|
readonly msg: string;
|
||||||
|
readonly raw: JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseKickServerPayload(value: unknown): ParsedKickPayload {
|
||||||
|
const raw = requireRecord(value, '$.data');
|
||||||
|
return { msg: requireString(raw.msg, '$.data.msg'), raw };
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type JsonRecord = Readonly<Record<string, unknown>>;
|
||||||
|
|
||||||
|
function receivedType(value: unknown): string {
|
||||||
|
if (value === null) return 'null';
|
||||||
|
if (Array.isArray(value)) return 'array';
|
||||||
|
return typeof value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(path: string, expected: string, value: unknown): never {
|
||||||
|
throw new TypeError(`${path}: expected ${expected}, received ${receivedType(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasOwn(record: JsonRecord, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(record, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRecord(value: unknown, path: string): JsonRecord {
|
||||||
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return invalid(path, 'record', value);
|
||||||
|
}
|
||||||
|
return value as JsonRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireString(value: unknown, path: string): string {
|
||||||
|
if (typeof value !== 'string') return invalid(path, 'string', value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireNumber(value: unknown, path: string): number {
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||||
|
return invalid(path, 'finite number', value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireInteger(value: unknown, path: string): number {
|
||||||
|
const number = requireNumber(value, path);
|
||||||
|
if (!Number.isInteger(number)) return invalid(path, 'integer', value);
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireArray(value: unknown, path: string): readonly unknown[] {
|
||||||
|
if (!Array.isArray(value)) return invalid(path, 'array', value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalField<T>(
|
||||||
|
record: JsonRecord,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
validate: (value: unknown, path: string) => T,
|
||||||
|
): T | undefined {
|
||||||
|
if (!hasOwn(record, key)) return undefined;
|
||||||
|
return validate(record[key], `${path}.${key}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { APP } from '../core/constants.ts';
|
||||||
|
|
||||||
|
export type FirstSliceRpc =
|
||||||
|
| 'player_login'
|
||||||
|
| 'self_join_room'
|
||||||
|
| 'player_prepare'
|
||||||
|
| 'self_exit_room'
|
||||||
|
| 'other_join_room'
|
||||||
|
| 'other_exit_room'
|
||||||
|
| 'other_offline'
|
||||||
|
| 'other_online'
|
||||||
|
| 'connect_roomserver'
|
||||||
|
| 'connect_agentserver'
|
||||||
|
| 'kick_server';
|
||||||
|
|
||||||
|
export type FirstSliceRoute = 'platform' | 'agent' | 'room';
|
||||||
|
|
||||||
|
/** 第一纵向切片 rpc → route 的唯一权威映射。 */
|
||||||
|
export const FIRST_SLICE_RPC_ROUTE = {
|
||||||
|
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 satisfies Record<FirstSliceRpc, 'platform' | 'agent' | 'room'>;
|
||||||
|
|
||||||
|
export function routeForFirstSlice<Rpc extends FirstSliceRpc>(
|
||||||
|
rpc: Rpc,
|
||||||
|
): (typeof FIRST_SLICE_RPC_ROUTE)[Rpc] {
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE, rpc)) {
|
||||||
|
throw new Error(`FIRST_SLICE_RPC_ROUTE 未定义 rpc: ${String(rpc)}`);
|
||||||
|
}
|
||||||
|
return FIRST_SLICE_RPC_ROUTE[rpc];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FirstSliceEnvelope<
|
||||||
|
Rpc extends FirstSliceRpc,
|
||||||
|
Data extends Readonly<Record<string, unknown>>,
|
||||||
|
> {
|
||||||
|
readonly app: 'youle';
|
||||||
|
readonly route: (typeof FIRST_SLICE_RPC_ROUTE)[Rpc];
|
||||||
|
readonly rpc: Rpc;
|
||||||
|
readonly data: Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFirstSliceEnvelope<
|
||||||
|
Rpc extends FirstSliceRpc,
|
||||||
|
Data extends Readonly<Record<string, unknown>>,
|
||||||
|
>(rpc: Rpc, data: Data): FirstSliceEnvelope<Rpc, Data> {
|
||||||
|
return { app: APP, route: routeForFirstSlice(rpc), rpc, data };
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"route":"agent","rpc":"player_login","data":{"state":0,"playerid":430511,"agentid":"veRa0qrBf","channelid":"FtJf073aa","nickname":"测试号","avatar":"http://a","openid":"o1","sex":0,"unionid":"test_unionid","roomcard":3,"bean":0,"score":0,"invitecode":null,"advanced":0,"taskstate":1,"ip":"127.0.0.1","bankpower":1,"bank":0,"sign":null,"tel":null,"initCard":"3","initBean":"0","bankpwd":0,"agentname":"进贤","agentmode":2,"gameversion":41,"roomcode":"100001","seat":1,"isowner":1,"roomtype":[1,4,1,2,2,[1,1,[1,2000,10],null,null,1],[1,0,5]],"asetcount":8,"isbattle":1,"makewar":4,"players":[null,{"playerid":430511,"nickname":"测试号","avatar":"http://a","sex":0,"bean":0,"isprepare":1,"onstate":1},null,null],"roommode":0,"beanlimit":0,"needprepare":1,"infinite":0,"rebateNumber":0,"rebateMode":0,"rebateType":0,"deskinfo":{"round":3,"cards":[1,2,3]}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"route":"agent","rpc":"player_login","data":{"state":0,"playerid":430511,"agentid":"veRa0qrBf","channelid":"FtJf073aa","nickname":"测试号","avatar":"http://a","openid":"o1","sex":0,"unionid":"test_unionid","roomcard":3,"bean":0,"score":0,"invitecode":null,"advanced":0,"taskstate":1,"ip":"127.0.0.1","bankpower":1,"bank":0,"sign":null,"tel":null,"initCard":"3","initBean":"0","bankpwd":0,"agentname":"进贤","agentmode":2,"gameversion":41}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"route":"agent","rpc":"self_join_room","data":{"state":0,"roomcode":"100001","seat":1,"isowner":1,"roomtype":[1,4,1,2,2,[1,1,[1,2000,10],null,null,1],[1,0,5]],"asetcount":8,"makewar":4,"players":[null,{"playerid":430511,"nickname":"测试号","avatar":"http://a","sex":0,"bean":0,"isprepare":1,"onstate":1},null,null],"roommode":0,"beanlimit":0,"needprepare":1,"infinite":0,"rebateNumber":0,"rebateMode":0,"rebateType":0,"deskwar":1,"deskinfo":{"round":3,"cards":[1,2,3]}}}
|
||||||
@@ -34,6 +34,13 @@ test('decodeFrame:带 app 字段的单层业务包(服务器实测格式)', ()
|
|||||||
assert.equal(r.kind === 'message' && r.message.rpc, 'kick_server');
|
assert.equal(r.kind === 'message' && r.message.rpc, 'kick_server');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('decodeFrame:拒绝把双层 data 信封误当服务器业务包', () => {
|
||||||
|
const nested = JSON.stringify({
|
||||||
|
data: { route: 'agent', rpc: 'kick_server', data: { msg: '检查到新版本' } },
|
||||||
|
});
|
||||||
|
assert.deepEqual(decodeFrame(nested), { kind: 'ignore' });
|
||||||
|
});
|
||||||
|
|
||||||
test('decodeFrame:无法解析的帧安全忽略', () => {
|
test('decodeFrame:无法解析的帧安全忽略', () => {
|
||||||
assert.deepEqual(decodeFrame('not json'), { kind: 'ignore' });
|
assert.deepEqual(decodeFrame('not json'), { kind: 'ignore' });
|
||||||
assert.deepEqual(decodeFrame(JSON.stringify({ foo: 1 })), { kind: 'ignore' });
|
assert.deepEqual(decodeFrame(JSON.stringify({ foo: 1 })), { kind: 'ignore' });
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
buildExitRoomRequest,
|
||||||
|
buildJoinRoomRequest,
|
||||||
|
buildLoginRequest,
|
||||||
|
buildPrepareRequest,
|
||||||
|
parseConnectAgentserverPayload,
|
||||||
|
parseConnectRoomserverPayload,
|
||||||
|
parseKickServerPayload,
|
||||||
|
parseLoginResponse,
|
||||||
|
parseOtherExitRoomPayload,
|
||||||
|
parseOtherJoinRoomPayload,
|
||||||
|
parseOtherOfflinePayload,
|
||||||
|
parseOtherOnlinePayload,
|
||||||
|
parsePlayerPreparePayload,
|
||||||
|
parseSelfJoinRoomResponse,
|
||||||
|
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
|
||||||
|
|
||||||
|
type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function owns(record: object, key: string): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(record, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(name: string): { route: string; rpc: string; data: JsonRecord } {
|
||||||
|
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfig = {
|
||||||
|
identity: {
|
||||||
|
agentid: 'veRa0qrBf',
|
||||||
|
gameid: 41,
|
||||||
|
version: 10000,
|
||||||
|
channelid: 'FtJf073aa',
|
||||||
|
marketid: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
openid: 'o1',
|
||||||
|
nickname: '测试号',
|
||||||
|
avatar: 'http://a',
|
||||||
|
sex: 0,
|
||||||
|
province: '江西',
|
||||||
|
city: '南昌',
|
||||||
|
unionid: 'test_unionid',
|
||||||
|
};
|
||||||
|
|
||||||
|
const device = {
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
location: { latitude: 1, longitude: 2 },
|
||||||
|
machineid: 'machine-1',
|
||||||
|
machineroom: 'room-install-1',
|
||||||
|
deviceLogin: { enabled: false as const },
|
||||||
|
loginPlayerId: { enabled: false as const },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('buildLoginRequest 生成逐字段精确的单层登录信封且不修改来源', () => {
|
||||||
|
const before = structuredClone(account);
|
||||||
|
const request = buildLoginRequest(runtimeConfig, account, device);
|
||||||
|
|
||||||
|
assert.deepEqual(request, {
|
||||||
|
app: 'youle', route: 'agent', rpc: 'player_login', data: {
|
||||||
|
agentid: 'veRa0qrBf', gameid: 41, openid: 'o1', nickname: '测试号',
|
||||||
|
avatar: 'http://a', sex: 0, province: '江西', city: '南昌',
|
||||||
|
unionid: 'test_unionid', version: 10000, channelid: 'FtJf073aa',
|
||||||
|
marketid: 4, ip: '127.0.0.1', location: { latitude: 1, longitude: 2 },
|
||||||
|
machineid: 'machine-1', machineroom: 'room-install-1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(account, before);
|
||||||
|
assert.notEqual(request.data, account);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLoginRequest 只在显式设备登录模式加入电话字段', () => {
|
||||||
|
const request = buildLoginRequest(runtimeConfig, account, {
|
||||||
|
...device,
|
||||||
|
deviceLogin: { enabled: true, telphone: '13800000000' },
|
||||||
|
});
|
||||||
|
assert.equal(request.data.telphone, '13800000000');
|
||||||
|
assert.equal(request.data.telphoneAuto, true);
|
||||||
|
|
||||||
|
const disabled = buildLoginRequest(runtimeConfig, account, {
|
||||||
|
...device,
|
||||||
|
deviceLogin: { enabled: false, telphone: '不得发送' } as { enabled: false },
|
||||||
|
});
|
||||||
|
assert.equal(owns(disabled.data, 'telphone'), false);
|
||||||
|
assert.equal(owns(disabled.data, 'telphoneAuto'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLoginRequest 只在显式复用模式且缓存 ID 有效时加入 playerid', () => {
|
||||||
|
const enabled = buildLoginRequest(runtimeConfig, account, {
|
||||||
|
...device,
|
||||||
|
loginPlayerId: { enabled: true, cachedPlayerId: 430511 },
|
||||||
|
});
|
||||||
|
assert.equal(enabled.data.playerid, 430511);
|
||||||
|
|
||||||
|
for (const cachedPlayerId of [-1, 1.5, '430511', null]) {
|
||||||
|
const invalid = buildLoginRequest(runtimeConfig, account, {
|
||||||
|
...device,
|
||||||
|
loginPlayerId: { enabled: true, cachedPlayerId },
|
||||||
|
});
|
||||||
|
assert.equal(owns(invalid.data, 'playerid'), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLoginRequest 缺少可选 IP 时省略 ip,但保留可为 null 的 location', () => {
|
||||||
|
const { ip: _drop, ...withoutIp } = device;
|
||||||
|
const request = buildLoginRequest(runtimeConfig, account, { ...withoutIp, location: null });
|
||||||
|
assert.equal(owns(request.data, 'ip'), false);
|
||||||
|
assert.equal(owns(request.data, 'location'), true);
|
||||||
|
assert.equal(request.data.location, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录与进房拒绝会在 JSON 序列化时消失的 undefined location', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => buildLoginRequest(runtimeConfig, account, { ...device, location: undefined }),
|
||||||
|
/\$\.device\.location.*undefined/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => buildJoinRoomRequest(
|
||||||
|
runtimeConfig,
|
||||||
|
{ playerid: 430511, roomcode: '100001' },
|
||||||
|
{ location: undefined, ip: '127.0.0.1' },
|
||||||
|
),
|
||||||
|
/\$\.environment\.location.*undefined/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录与进房的 location 只接受原样对象或 null', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => buildLoginRequest(runtimeConfig, account, { ...device, location: '南昌' }),
|
||||||
|
/\$\.device\.location.*string/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => buildJoinRoomRequest(
|
||||||
|
runtimeConfig,
|
||||||
|
{ playerid: 430511, roomcode: '100001' },
|
||||||
|
{ location: 28.7, ip: '127.0.0.1' },
|
||||||
|
),
|
||||||
|
/\$\.environment\.location.*number/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLoginRequest 拒绝非数字 version,并报告精确 JSON path 与类型', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => buildLoginRequest(
|
||||||
|
{ identity: { ...runtimeConfig.identity, version: '10000' } } as never,
|
||||||
|
account,
|
||||||
|
device,
|
||||||
|
),
|
||||||
|
/\$\.runtimeConfig\.identity\.version.*string/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseLoginResponse 接受已验证大厅登录金样并保留 raw', () => {
|
||||||
|
const data = fixture('player-login-success.json').data;
|
||||||
|
const parsed = parseLoginResponse(data);
|
||||||
|
assert.equal(parsed.state, 0);
|
||||||
|
assert.equal(parsed.playerid, 430511);
|
||||||
|
assert.equal(parsed.room, null);
|
||||||
|
assert.deepEqual(parsed.reconnect, { present: false });
|
||||||
|
assert.equal(parsed.raw, data);
|
||||||
|
assert.equal(parsed.raw.agentname, '进贤');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseLoginResponse 由 deskinfo truthiness 决定 reconnect 并保持同一引用', () => {
|
||||||
|
const data = fixture('player-login-room.json').data;
|
||||||
|
const parsed = parseLoginResponse(data);
|
||||||
|
assert.equal(parsed.room?.roomcode, '100001');
|
||||||
|
assert.equal(parsed.room?.seat, 1);
|
||||||
|
assert.equal(parsed.room?.roomtype, data.roomtype);
|
||||||
|
assert.equal(parsed.room?.players, data.players);
|
||||||
|
assert.equal(parsed.reconnect.present, true);
|
||||||
|
assert.equal(parsed.reconnect.present && parsed.reconnect.value, data.deskinfo);
|
||||||
|
|
||||||
|
const { deskinfo: _drop, ...withoutDeskinfo } = data;
|
||||||
|
assert.equal(parseLoginResponse({ ...withoutDeskinfo, isbattle: 1 }).reconnect.present, false);
|
||||||
|
assert.equal(parseLoginResponse({ ...withoutDeskinfo, deskinfo: null }).reconnect.present, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseLoginResponse 严格拒绝缺 state、成功但缺 playerid 和不完整房间快照', () => {
|
||||||
|
assert.throws(() => parseLoginResponse({ playerid: 1 }), /\$\.data\.state.*undefined/);
|
||||||
|
assert.throws(() => parseLoginResponse({ state: 0 }), /\$\.data\.playerid.*undefined/);
|
||||||
|
|
||||||
|
const full = fixture('player-login-room.json').data;
|
||||||
|
for (const field of ['roomcode', 'seat', 'roomtype', 'players'] as const) {
|
||||||
|
const malformed = { ...full };
|
||||||
|
delete malformed[field];
|
||||||
|
assert.throws(() => parseLoginResponse(malformed), new RegExp(`\\$\\.data\\.${field}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseLoginResponse 拒绝负数、越界及非整数 seat', () => {
|
||||||
|
const full = fixture('player-login-room.json').data;
|
||||||
|
for (const seat of [-1, 4, 1.5]) {
|
||||||
|
assert.throws(() => parseLoginResponse({ ...full, seat }), /\$\.data\.seat/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSelfJoinRoomResponse 接受金样,不深验 roomtype/deskinfo 并保留引用', () => {
|
||||||
|
const data = fixture('self-join-room.json').data;
|
||||||
|
const opaqueDeskinfo = new Date();
|
||||||
|
const roomtype = [undefined, { gameDefined: true }];
|
||||||
|
const parsed = parseSelfJoinRoomResponse({ ...data, roomtype, deskinfo: opaqueDeskinfo });
|
||||||
|
assert.equal(parsed.state, 0);
|
||||||
|
assert.equal(parsed.room?.roomtype, roomtype);
|
||||||
|
assert.equal(parsed.reconnect.present, true);
|
||||||
|
assert.equal(parsed.reconnect.present && parsed.reconnect.value, opaqueDeskinfo);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('房间请求 builder 使用唯一 route,并仅发协议定义字段', () => {
|
||||||
|
const room = { playerid: 430511, roomcode: '100001' };
|
||||||
|
assert.deepEqual(buildPrepareRequest(runtimeConfig, room), {
|
||||||
|
app: 'youle', route: 'room', rpc: 'player_prepare',
|
||||||
|
data: { agentid: 'veRa0qrBf', playerid: 430511, gameid: 41, roomcode: '100001' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildExitRoomRequest(runtimeConfig, room), {
|
||||||
|
app: 'youle', route: 'room', rpc: 'self_exit_room',
|
||||||
|
data: { agentid: 'veRa0qrBf', playerid: 430511, gameid: 41, roomcode: '100001' },
|
||||||
|
});
|
||||||
|
assert.deepEqual(buildJoinRoomRequest(runtimeConfig, room, {
|
||||||
|
location: null,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
vipMatch: 1,
|
||||||
|
match_id: 'match-7',
|
||||||
|
}), {
|
||||||
|
app: 'youle', route: 'agent', rpc: 'self_join_room',
|
||||||
|
data: {
|
||||||
|
agentid: 'veRa0qrBf', playerid: 430511, gameid: 41, roomcode: '100001',
|
||||||
|
location: null, ip: '127.0.0.1', vipMatch: 1, match_id: 'match-7',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildJoinRoomRequest 不凭空补 vipMatch 或 match_id', () => {
|
||||||
|
const request = buildJoinRoomRequest(
|
||||||
|
runtimeConfig,
|
||||||
|
{ playerid: 430511, roomcode: '100001' },
|
||||||
|
{ location: null, ip: '127.0.0.1' },
|
||||||
|
);
|
||||||
|
assert.equal(owns(request.data, 'vipMatch'), false);
|
||||||
|
assert.equal(owns(request.data, 'match_id'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('座位事件 parser 校验实际消费字段并保留 raw', () => {
|
||||||
|
const prepare = { seat: 1, deskwar: 1, extension: 'kept' };
|
||||||
|
assert.equal(parsePlayerPreparePayload(prepare).raw, prepare);
|
||||||
|
assert.equal(parseOtherJoinRoomPayload({ seat: 1, playerid: 9 }).seat, 1);
|
||||||
|
assert.equal(parseOtherExitRoomPayload({ seat: 1 }).seat, 1);
|
||||||
|
assert.equal(parseOtherOfflinePayload({ seat: 1 }).seat, 1);
|
||||||
|
assert.deepEqual(parseOtherOnlinePayload({ seat: 1, ip: '127.0.0.2' }), {
|
||||||
|
seat: 1,
|
||||||
|
ip: '127.0.0.2',
|
||||||
|
raw: { seat: 1, ip: '127.0.0.2' },
|
||||||
|
});
|
||||||
|
assert.throws(() => parseOtherExitRoomPayload({ seat: 1.2 }), /\$\.data\.seat.*number/);
|
||||||
|
assert.throws(() => parseOtherOnlinePayload({ seat: 1 }), /\$\.data\.ip.*undefined/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('切服与踢出 parser 拒绝畸形载荷并保留未知扩展', () => {
|
||||||
|
const roomSwitch = { roomserver: '127.0.0.1:9001', epoch: 7 };
|
||||||
|
assert.deepEqual(parseConnectRoomserverPayload(roomSwitch), {
|
||||||
|
roomserver: '127.0.0.1:9001',
|
||||||
|
raw: roomSwitch,
|
||||||
|
});
|
||||||
|
const agentSwitch = { agentserver: '127.0.0.1:9002', opt: 'free_room', trace: 'x' };
|
||||||
|
assert.deepEqual(parseConnectAgentserverPayload(agentSwitch), {
|
||||||
|
agentserver: '127.0.0.1:9002',
|
||||||
|
opt: 'free_room',
|
||||||
|
raw: agentSwitch,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseKickServerPayload({ msg: '检查到新版本', code: 1 }).msg, '检查到新版本');
|
||||||
|
|
||||||
|
assert.throws(() => parseConnectRoomserverPayload({ roomserver: 9001 }), /\$\.data\.roomserver.*number/);
|
||||||
|
assert.throws(() => parseConnectAgentserverPayload({ agentserver: 'x' }), /\$\.data\.opt.*undefined/);
|
||||||
|
assert.throws(() => parseConnectAgentserverPayload({ agentserver: null, opt: 'free_room' }), /\$\.data\.agentserver.*null/);
|
||||||
|
assert.throws(() => parseKickServerPayload({ msg: false }), /\$\.data\.msg.*boolean/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
FIRST_SLICE_RPC_ROUTE,
|
||||||
|
routeForFirstSlice,
|
||||||
|
} from '../../YouleNexus/assets/framework/protocol/first-slice-routes.ts';
|
||||||
|
|
||||||
|
test('FIRST_SLICE_RPC_ROUTE 是第一纵向切片唯一且精确的 rpc route 表', () => {
|
||||||
|
assert.deepEqual(FIRST_SLICE_RPC_ROUTE, {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('routeForFirstSlice 从单源映射取 route,未知 rpc 显式失败', () => {
|
||||||
|
assert.equal(routeForFirstSlice('self_join_room'), 'agent');
|
||||||
|
assert.equal(routeForFirstSlice('connect_roomserver'), 'room');
|
||||||
|
assert.throws(
|
||||||
|
() => routeForFirstSlice('unknown_rpc' as never),
|
||||||
|
/unknown_rpc/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user