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 };
}