From 0a7951ba32e37cc67570eab5688f3c61a100ab1f Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 02:06:46 +0800 Subject: [PATCH] feat(protocol): define strict first-slice wire contracts --- .../framework/protocol/contracts/index.ts | 48 +++ .../protocol/contracts/login-contract.ts | 164 ++++++++++ .../protocol/contracts/room-contracts.ts | 205 +++++++++++++ .../protocol/contracts/validation.ts | 55 ++++ .../framework/protocol/first-slice-routes.ts | 57 ++++ .../fixtures/contracts/player-login-room.json | 1 + .../contracts/player-login-success.json | 1 + .../fixtures/contracts/self-join-room.json | 1 + .../net/envelope-codec.test.ts | 7 + .../protocol/contracts.test.ts | 281 ++++++++++++++++++ .../protocol/first-slice-routes.test.ts | 31 ++ 11 files changed, 851 insertions(+) create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts create mode 100644 cocoscreator_projects/YouleNexus/assets/framework/protocol/first-slice-routes.ts create mode 100644 cocoscreator_projects/framework-tests/fixtures/contracts/player-login-room.json create mode 100644 cocoscreator_projects/framework-tests/fixtures/contracts/player-login-success.json create mode 100644 cocoscreator_projects/framework-tests/fixtures/contracts/self-join-room.json create mode 100644 cocoscreator_projects/framework-tests/protocol/contracts.test.ts create mode 100644 cocoscreator_projects/framework-tests/protocol/first-slice-routes.test.ts diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts new file mode 100644 index 0000000..6c5a685 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts @@ -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'; diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts new file mode 100644 index 0000000..d17ff78 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.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> { + 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 = { + 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, + }; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts new file mode 100644 index 0000000..ebe647b --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts @@ -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> { + 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 = { + ...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 }; +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts new file mode 100644 index 0000000..33193bb --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts @@ -0,0 +1,55 @@ +export type JsonRecord = Readonly>; + +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( + 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}`); +} diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/first-slice-routes.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/first-slice-routes.ts new file mode 100644 index 0000000..9752d95 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/first-slice-routes.ts @@ -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; + +export function routeForFirstSlice( + 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>, +> { + 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>, +>(rpc: Rpc, data: Data): FirstSliceEnvelope { + return { app: APP, route: routeForFirstSlice(rpc), rpc, data }; +} diff --git a/cocoscreator_projects/framework-tests/fixtures/contracts/player-login-room.json b/cocoscreator_projects/framework-tests/fixtures/contracts/player-login-room.json new file mode 100644 index 0000000..f256428 --- /dev/null +++ b/cocoscreator_projects/framework-tests/fixtures/contracts/player-login-room.json @@ -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]}}} diff --git a/cocoscreator_projects/framework-tests/fixtures/contracts/player-login-success.json b/cocoscreator_projects/framework-tests/fixtures/contracts/player-login-success.json new file mode 100644 index 0000000..3de5fc0 --- /dev/null +++ b/cocoscreator_projects/framework-tests/fixtures/contracts/player-login-success.json @@ -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}} diff --git a/cocoscreator_projects/framework-tests/fixtures/contracts/self-join-room.json b/cocoscreator_projects/framework-tests/fixtures/contracts/self-join-room.json new file mode 100644 index 0000000..4c8c768 --- /dev/null +++ b/cocoscreator_projects/framework-tests/fixtures/contracts/self-join-room.json @@ -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]}}} diff --git a/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts b/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts index 3ea7aea..42021f1 100644 --- a/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts +++ b/cocoscreator_projects/framework-tests/net/envelope-codec.test.ts @@ -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' }); diff --git a/cocoscreator_projects/framework-tests/protocol/contracts.test.ts b/cocoscreator_projects/framework-tests/protocol/contracts.test.ts new file mode 100644 index 0000000..4bcda82 --- /dev/null +++ b/cocoscreator_projects/framework-tests/protocol/contracts.test.ts @@ -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; + +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/); +}); diff --git a/cocoscreator_projects/framework-tests/protocol/first-slice-routes.test.ts b/cocoscreator_projects/framework-tests/protocol/first-slice-routes.test.ts new file mode 100644 index 0000000..c05abb9 --- /dev/null +++ b/cocoscreator_projects/framework-tests/protocol/first-slice-routes.test.ts @@ -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/, + ); +});