Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
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,
|
|
parseSelfExitRoomPayload,
|
|
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, 0, 1.5, Number.NaN, Infinity, '430511', null, undefined]) {
|
|
const type = cachedPlayerId === null ? 'null' : typeof cachedPlayerId;
|
|
assert.throws(
|
|
() => buildLoginRequest(runtimeConfig, account, {
|
|
...device,
|
|
loginPlayerId: { enabled: true, cachedPlayerId },
|
|
}),
|
|
new RegExp(`\\$\\.device\\.loginPlayerId\\.cachedPlayerId.*${type}`),
|
|
);
|
|
}
|
|
});
|
|
|
|
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 接受可传输的 opaque 游戏字段并保留引用', () => {
|
|
const data = fixture('self-join-room.json').data;
|
|
const opaqueDeskinfo = { future: { round: 9, unknownCards: [17, 29] } };
|
|
const roomtype = [null, { gameDefined: true, futureRule: ['unknown', 7] }];
|
|
const unknownGameField = { nextVersion: ['kept', false] };
|
|
const parsed = parseSelfJoinRoomResponse({ ...data, roomtype, deskinfo: opaqueDeskinfo, unknownGameField });
|
|
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);
|
|
assert.equal(parsed.raw.unknownGameField, unknownGameField);
|
|
assert.throws(() => parseSelfJoinRoomResponse({ ...data, roomtype: [undefined, { gameDefined: true }] }), /\$\.data\.roomtype\.0.*finite JSON value/);
|
|
});
|
|
|
|
test('parseSelfJoinRoomResponse defines the optional deskwar default once and deskinfo wins stage', () => {
|
|
const cases = [
|
|
{ deskwar: 'missing', deskinfo: false, expectedStage: 0 },
|
|
{ deskwar: 0, deskinfo: false, expectedStage: 0 },
|
|
{ deskwar: 1, deskinfo: false, expectedStage: 1 },
|
|
{ deskwar: 'missing', deskinfo: true, expectedStage: 1 },
|
|
{ deskwar: 0, deskinfo: true, expectedStage: 1 },
|
|
{ deskwar: 1, deskinfo: true, expectedStage: 1 },
|
|
] as const;
|
|
|
|
for (const row of cases) {
|
|
const data = fixture('self-join-room.json').data;
|
|
if (row.deskwar === 'missing') delete data.deskwar;
|
|
else data.deskwar = row.deskwar;
|
|
if (row.deskinfo) data.deskinfo = { round: 7 };
|
|
else delete data.deskinfo;
|
|
data.isbattle = 1;
|
|
|
|
const parsed = parseSelfJoinRoomResponse(data);
|
|
|
|
assert.equal(parsed.stage, row.expectedStage, JSON.stringify(row));
|
|
assert.equal(parsed.reconnect.present, row.deskinfo, JSON.stringify(row));
|
|
}
|
|
|
|
for (const deskwar of [-1, 2, 1.5, '1', undefined]) {
|
|
assert.throws(
|
|
() => parseSelfJoinRoomResponse({ ...fixture('self-join-room.json').data, deskwar }),
|
|
/\$\.data\.deskwar/,
|
|
);
|
|
}
|
|
});
|
|
|
|
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('房间请求拒绝 agentid/gameid 的非有限数值并报告来源路径', () => {
|
|
for (const field of ['agentid', 'gameid'] as const) {
|
|
for (const value of [Number.NaN, Infinity, -Infinity]) {
|
|
assert.throws(
|
|
() => buildPrepareRequest(
|
|
{ identity: { ...runtimeConfig.identity, [field]: value } },
|
|
{ playerid: 430511, roomcode: '100001' },
|
|
),
|
|
new RegExp(`\\$\\.runtimeConfig\\.identity\\.${field}.*number`),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
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('buildJoinRoomRequest 拒绝非法或 undefined vipMatch', () => {
|
|
for (const vipMatch of [undefined, 0, 2, '1', Number.NaN, Infinity]) {
|
|
const type = typeof vipMatch;
|
|
assert.throws(
|
|
() => buildJoinRoomRequest(
|
|
runtimeConfig,
|
|
{ playerid: 430511, roomcode: '100001' },
|
|
{ location: null, ip: '127.0.0.1', vipMatch } as never,
|
|
),
|
|
new RegExp(`\\$\\.environment\\.vipMatch.*${type}`),
|
|
);
|
|
}
|
|
});
|
|
|
|
test('buildJoinRoomRequest 拒绝非法、undefined 或非有限 match_id', () => {
|
|
for (const match_id of [undefined, null, false, {}, Number.NaN, Infinity]) {
|
|
const type = match_id === null ? 'null' : Array.isArray(match_id) ? 'array' : typeof match_id;
|
|
assert.throws(
|
|
() => buildJoinRoomRequest(
|
|
runtimeConfig,
|
|
{ playerid: 430511, roomcode: '100001' },
|
|
{ location: null, ip: '127.0.0.1', match_id } as never,
|
|
),
|
|
new RegExp(`\\$\\.environment\\.match_id.*${type}`),
|
|
);
|
|
}
|
|
|
|
assert.equal(buildJoinRoomRequest(
|
|
runtimeConfig,
|
|
{ playerid: 430511, roomcode: '100001' },
|
|
{ location: null, ip: '127.0.0.1', match_id: 7 },
|
|
).data.match_id, 7);
|
|
});
|
|
|
|
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('self_exit_room parser accepts an omitted seat but strictly validates it when present', () => {
|
|
const withoutSeat = { isowner: 1, roomcode: '100001', extension: 'kept' };
|
|
const parsedWithoutSeat = parseSelfExitRoomPayload(withoutSeat);
|
|
assert.equal(parsedWithoutSeat.raw, withoutSeat);
|
|
assert.equal(Object.prototype.hasOwnProperty.call(parsedWithoutSeat, 'seat'), false);
|
|
|
|
const withSeat = { seat: 1, extension: 'kept' };
|
|
const parsedWithSeat = parseSelfExitRoomPayload(withSeat);
|
|
assert.equal(parsedWithSeat.raw, withSeat);
|
|
assert.equal(parsedWithSeat.seat, 1);
|
|
|
|
for (const seat of [-1, 1.2, '1', undefined]) {
|
|
assert.throws(() => parseSelfExitRoomPayload({ seat }), /\$\.data\.seat/);
|
|
}
|
|
assert.throws(() => parseOtherExitRoomPayload({}), /\$\.data\.seat/);
|
|
});
|
|
|
|
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/);
|
|
});
|