feat(protocol): define strict first-slice wire contracts

This commit is contained in:
2026-09-05 02:06:46 +08:00
parent a173d410e0
commit 0a7951ba32
11 changed files with 851 additions and 0 deletions
@@ -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';
@@ -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,
};
}
@@ -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');
});
test('decodeFrame:拒绝把双层 data 信封误当服务器业务包', () => {
const nested = JSON.stringify({
data: { route: 'agent', rpc: 'kick_server', data: { msg: '检查到新版本' } },
});
assert.deepEqual(decodeFrame(nested), { kind: 'ignore' });
});
test('decodeFrame:无法解析的帧安全忽略', () => {
assert.deepEqual(decodeFrame('not json'), { 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/,
);
});