feat(protocol): define strict first-slice wire contracts
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
export {
|
||||
hasOwn,
|
||||
optionalField,
|
||||
requireArray,
|
||||
requireInteger,
|
||||
requireNumber,
|
||||
requireRecord,
|
||||
requireString,
|
||||
type JsonRecord,
|
||||
} from './validation.ts';
|
||||
export {
|
||||
buildLoginRequest,
|
||||
parseLoginResponse,
|
||||
type DeviceLoginMode,
|
||||
type FirstSliceRuntimeConfig,
|
||||
type LoginAccountIdentity,
|
||||
type LoginDeviceSnapshot,
|
||||
type LoginPlayerIdMode,
|
||||
type LoginRequestPayload,
|
||||
type ParsedLoginResponse,
|
||||
} from './login-contract.ts';
|
||||
export {
|
||||
buildExitRoomRequest,
|
||||
buildJoinRoomRequest,
|
||||
buildPrepareRequest,
|
||||
parseConnectAgentserverPayload,
|
||||
parseConnectRoomserverPayload,
|
||||
parseKickServerPayload,
|
||||
parseOtherExitRoomPayload,
|
||||
parseOtherJoinRoomPayload,
|
||||
parseOtherOfflinePayload,
|
||||
parseOtherOnlinePayload,
|
||||
parsePlayerPreparePayload,
|
||||
parseSelfJoinRoomResponse,
|
||||
type JoinRoomEnvironment,
|
||||
type JoinRoomPayload,
|
||||
type ParsedAgentserverPayload,
|
||||
type ParsedKickPayload,
|
||||
type ParsedOnlinePayload,
|
||||
type ParsedRoomEntry,
|
||||
type ParsedRoomResponse,
|
||||
type ParsedRoomserverPayload,
|
||||
type ParsedRoomSnapshot,
|
||||
type ParsedSeatPayload,
|
||||
type ReconnectSnapshot,
|
||||
type RoomActionPayload,
|
||||
type RoomIdentity,
|
||||
} from './room-contracts.ts';
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
buildFirstSliceEnvelope,
|
||||
type FirstSliceEnvelope,
|
||||
} from '../first-slice-routes.ts';
|
||||
import {
|
||||
hasOwn,
|
||||
optionalField,
|
||||
requireInteger,
|
||||
requireNumber,
|
||||
requireRecord,
|
||||
requireString,
|
||||
type JsonRecord,
|
||||
} from './validation.ts';
|
||||
import {
|
||||
parseRoomEntryFields,
|
||||
reconnectFrom,
|
||||
type ParsedRoomSnapshot,
|
||||
type ReconnectSnapshot,
|
||||
} from './room-contracts.ts';
|
||||
|
||||
export interface FirstSliceRuntimeConfig {
|
||||
readonly identity: {
|
||||
readonly agentid: string | number;
|
||||
readonly gameid: string | number;
|
||||
readonly version: number;
|
||||
readonly channelid: string | number;
|
||||
readonly marketid: string | number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoginAccountIdentity {
|
||||
readonly openid: string;
|
||||
readonly nickname: string;
|
||||
readonly avatar: string;
|
||||
readonly sex: number;
|
||||
readonly province: string;
|
||||
readonly city: string;
|
||||
readonly unionid: string | number;
|
||||
}
|
||||
|
||||
export type DeviceLoginMode =
|
||||
| { readonly enabled: false }
|
||||
| { readonly enabled: true; readonly telphone: string };
|
||||
|
||||
export type LoginPlayerIdMode =
|
||||
| { readonly enabled: false }
|
||||
| { readonly enabled: true; readonly cachedPlayerId: unknown };
|
||||
|
||||
export interface LoginDeviceSnapshot {
|
||||
readonly ip?: string;
|
||||
readonly location: unknown;
|
||||
readonly machineid: string;
|
||||
readonly machineroom: string;
|
||||
readonly deviceLogin: DeviceLoginMode;
|
||||
readonly loginPlayerId: LoginPlayerIdMode;
|
||||
}
|
||||
|
||||
export interface LoginRequestPayload extends Readonly<Record<string, unknown>> {
|
||||
readonly agentid: string | number;
|
||||
readonly gameid: string | number;
|
||||
readonly openid: string;
|
||||
readonly nickname: string;
|
||||
readonly avatar: string;
|
||||
readonly sex: number;
|
||||
readonly province: string;
|
||||
readonly city: string;
|
||||
readonly unionid: string | number;
|
||||
readonly version: number;
|
||||
readonly channelid: string | number;
|
||||
readonly marketid: string | number;
|
||||
readonly location: unknown;
|
||||
readonly machineid: string;
|
||||
readonly machineroom: string;
|
||||
}
|
||||
|
||||
function requireStringOrNumber(value: unknown, path: string): string | number {
|
||||
if (typeof value === 'string') return value;
|
||||
return requireNumber(value, path);
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new TypeError(`${path}: expected boolean, received ${value === null ? 'null' : typeof value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildLoginRequest(
|
||||
runtimeConfig: FirstSliceRuntimeConfig,
|
||||
account: LoginAccountIdentity,
|
||||
device: LoginDeviceSnapshot,
|
||||
): FirstSliceEnvelope<'player_login', LoginRequestPayload> {
|
||||
const identity = requireRecord(runtimeConfig?.identity, '$.runtimeConfig.identity');
|
||||
const accountRecord = requireRecord(account, '$.account');
|
||||
const deviceRecord = requireRecord(device, '$.device');
|
||||
const location = hasOwn(deviceRecord, 'location') && deviceRecord.location !== undefined
|
||||
? deviceRecord.location
|
||||
: (() => { throw new TypeError('$.device.location: expected present field, received undefined'); })();
|
||||
if (location !== null) requireRecord(location, '$.device.location');
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
agentid: requireStringOrNumber(identity.agentid, '$.runtimeConfig.identity.agentid'),
|
||||
gameid: requireStringOrNumber(identity.gameid, '$.runtimeConfig.identity.gameid'),
|
||||
openid: requireString(accountRecord.openid, '$.account.openid'),
|
||||
nickname: requireString(accountRecord.nickname, '$.account.nickname'),
|
||||
avatar: requireString(accountRecord.avatar, '$.account.avatar'),
|
||||
sex: requireInteger(accountRecord.sex, '$.account.sex'),
|
||||
province: requireString(accountRecord.province, '$.account.province'),
|
||||
city: requireString(accountRecord.city, '$.account.city'),
|
||||
unionid: requireStringOrNumber(accountRecord.unionid, '$.account.unionid'),
|
||||
version: requireNumber(identity.version, '$.runtimeConfig.identity.version'),
|
||||
channelid: requireStringOrNumber(identity.channelid, '$.runtimeConfig.identity.channelid'),
|
||||
marketid: requireStringOrNumber(identity.marketid, '$.runtimeConfig.identity.marketid'),
|
||||
location,
|
||||
machineid: requireString(deviceRecord.machineid, '$.device.machineid'),
|
||||
machineroom: requireString(deviceRecord.machineroom, '$.device.machineroom'),
|
||||
};
|
||||
|
||||
const ip = optionalField(deviceRecord, 'ip', '$.device', requireString);
|
||||
if (ip !== undefined) data.ip = ip;
|
||||
|
||||
const deviceLogin = requireRecord(deviceRecord.deviceLogin, '$.device.deviceLogin');
|
||||
if (requireBoolean(deviceLogin.enabled, '$.device.deviceLogin.enabled')) {
|
||||
data.telphone = requireString(deviceLogin.telphone, '$.device.deviceLogin.telphone');
|
||||
data.telphoneAuto = true;
|
||||
}
|
||||
|
||||
const loginPlayerId = requireRecord(deviceRecord.loginPlayerId, '$.device.loginPlayerId');
|
||||
if (requireBoolean(loginPlayerId.enabled, '$.device.loginPlayerId.enabled')) {
|
||||
const cached = loginPlayerId.cachedPlayerId;
|
||||
if (typeof cached === 'number' && Number.isInteger(cached) && cached > 0) {
|
||||
data.playerid = cached;
|
||||
}
|
||||
}
|
||||
|
||||
return buildFirstSliceEnvelope('player_login', data as LoginRequestPayload);
|
||||
}
|
||||
|
||||
export interface ParsedLoginResponse {
|
||||
readonly state: number;
|
||||
readonly playerid?: number;
|
||||
readonly room: ParsedRoomSnapshot | null;
|
||||
readonly reconnect: ReconnectSnapshot;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseLoginResponse(value: unknown): ParsedLoginResponse {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
const state = requireInteger(raw.state, '$.data.state');
|
||||
const playerid = state === 0
|
||||
? requireInteger(raw.playerid, '$.data.playerid')
|
||||
: optionalField(raw, 'playerid', '$.data', requireInteger);
|
||||
const hasRoomFields = ['roomcode', 'seat', 'roomtype', 'players']
|
||||
.some((field) => hasOwn(raw, field));
|
||||
const room = hasRoomFields ? parseRoomEntryFields(raw) : null;
|
||||
|
||||
return {
|
||||
state,
|
||||
...(playerid === undefined ? {} : { playerid }),
|
||||
room,
|
||||
reconnect: reconnectFrom(raw),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
buildFirstSliceEnvelope,
|
||||
type FirstSliceEnvelope,
|
||||
} from '../first-slice-routes.ts';
|
||||
import {
|
||||
hasOwn,
|
||||
requireArray,
|
||||
requireInteger,
|
||||
requireRecord,
|
||||
requireString,
|
||||
type JsonRecord,
|
||||
} from './validation.ts';
|
||||
import type { FirstSliceRuntimeConfig } from './login-contract.ts';
|
||||
|
||||
export type ReconnectSnapshot =
|
||||
| { readonly present: false }
|
||||
| { readonly present: true; readonly value: unknown };
|
||||
|
||||
export interface ParsedRoomSnapshot {
|
||||
readonly roomcode: string;
|
||||
readonly seat: number;
|
||||
readonly roomtype: readonly unknown[];
|
||||
readonly players: readonly unknown[];
|
||||
}
|
||||
|
||||
export interface ParsedRoomEntry {
|
||||
readonly room: ParsedRoomSnapshot;
|
||||
readonly reconnect: ReconnectSnapshot;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function reconnectFrom(raw: JsonRecord): ReconnectSnapshot {
|
||||
if (!Boolean(raw.deskinfo)) return { present: false };
|
||||
return { present: true, value: raw.deskinfo };
|
||||
}
|
||||
|
||||
export function parseRoomEntryFields(raw: JsonRecord): ParsedRoomSnapshot {
|
||||
const roomcode = requireString(raw.roomcode, '$.data.roomcode');
|
||||
const seat = requireInteger(raw.seat, '$.data.seat');
|
||||
const roomtype = requireArray(raw.roomtype, '$.data.roomtype');
|
||||
const players = requireArray(raw.players, '$.data.players');
|
||||
if (seat < 0 || seat >= players.length) {
|
||||
throw new RangeError(`$.data.seat: expected server seat index within players, received number`);
|
||||
}
|
||||
return { roomcode, seat, roomtype, players };
|
||||
}
|
||||
|
||||
export interface RoomIdentity {
|
||||
readonly playerid: number;
|
||||
readonly roomcode: string;
|
||||
}
|
||||
|
||||
export interface RoomActionPayload extends Readonly<Record<string, unknown>> {
|
||||
readonly agentid: string | number;
|
||||
readonly playerid: number;
|
||||
readonly gameid: string | number;
|
||||
readonly roomcode: string;
|
||||
}
|
||||
|
||||
function baseRoomAction(
|
||||
runtimeConfig: FirstSliceRuntimeConfig,
|
||||
room: RoomIdentity,
|
||||
): RoomActionPayload {
|
||||
const identity = requireRecord(runtimeConfig?.identity, '$.runtimeConfig.identity');
|
||||
const roomRecord = requireRecord(room, '$.room');
|
||||
const agentid = identity.agentid;
|
||||
const gameid = identity.gameid;
|
||||
if (typeof agentid !== 'string' && typeof agentid !== 'number') {
|
||||
throw new TypeError(`$.runtimeConfig.identity.agentid: expected string or number, received ${typeof agentid}`);
|
||||
}
|
||||
if (typeof gameid !== 'string' && typeof gameid !== 'number') {
|
||||
throw new TypeError(`$.runtimeConfig.identity.gameid: expected string or number, received ${typeof gameid}`);
|
||||
}
|
||||
return {
|
||||
agentid,
|
||||
playerid: requireInteger(roomRecord.playerid, '$.room.playerid'),
|
||||
gameid,
|
||||
roomcode: requireString(roomRecord.roomcode, '$.room.roomcode'),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPrepareRequest(
|
||||
runtimeConfig: FirstSliceRuntimeConfig,
|
||||
room: RoomIdentity,
|
||||
): FirstSliceEnvelope<'player_prepare', RoomActionPayload> {
|
||||
return buildFirstSliceEnvelope('player_prepare', baseRoomAction(runtimeConfig, room));
|
||||
}
|
||||
|
||||
export function buildExitRoomRequest(
|
||||
runtimeConfig: FirstSliceRuntimeConfig,
|
||||
room: RoomIdentity,
|
||||
): FirstSliceEnvelope<'self_exit_room', RoomActionPayload> {
|
||||
return buildFirstSliceEnvelope('self_exit_room', baseRoomAction(runtimeConfig, room));
|
||||
}
|
||||
|
||||
export interface JoinRoomEnvironment {
|
||||
readonly location: unknown;
|
||||
readonly ip: string;
|
||||
readonly vipMatch?: 1;
|
||||
readonly match_id?: string | number;
|
||||
}
|
||||
|
||||
export interface JoinRoomPayload extends RoomActionPayload {
|
||||
readonly location: unknown;
|
||||
readonly ip: string;
|
||||
readonly vipMatch?: 1;
|
||||
readonly match_id?: string | number;
|
||||
}
|
||||
|
||||
export function buildJoinRoomRequest(
|
||||
runtimeConfig: FirstSliceRuntimeConfig,
|
||||
room: RoomIdentity,
|
||||
environment: JoinRoomEnvironment,
|
||||
): FirstSliceEnvelope<'self_join_room', JoinRoomPayload> {
|
||||
const environmentRecord = requireRecord(environment, '$.environment');
|
||||
if (!hasOwn(environmentRecord, 'location') || environmentRecord.location === undefined) {
|
||||
throw new TypeError('$.environment.location: expected present field, received undefined');
|
||||
}
|
||||
if (environmentRecord.location !== null) {
|
||||
requireRecord(environmentRecord.location, '$.environment.location');
|
||||
}
|
||||
const data: Record<string, unknown> = {
|
||||
...baseRoomAction(runtimeConfig, room),
|
||||
location: environmentRecord.location,
|
||||
ip: requireString(environmentRecord.ip, '$.environment.ip'),
|
||||
};
|
||||
if (hasOwn(environmentRecord, 'vipMatch')) data.vipMatch = environmentRecord.vipMatch;
|
||||
if (hasOwn(environmentRecord, 'match_id')) data.match_id = environmentRecord.match_id;
|
||||
return buildFirstSliceEnvelope('self_join_room', data as JoinRoomPayload);
|
||||
}
|
||||
|
||||
export interface ParsedRoomResponse {
|
||||
readonly state: number;
|
||||
readonly room: ParsedRoomSnapshot | null;
|
||||
readonly reconnect: ReconnectSnapshot;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseSelfJoinRoomResponse(value: unknown): ParsedRoomResponse {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
const state = requireInteger(raw.state, '$.data.state');
|
||||
const room = state === 0 ? parseRoomEntryFields(raw) : null;
|
||||
return { state, room, reconnect: reconnectFrom(raw), raw };
|
||||
}
|
||||
|
||||
export interface ParsedSeatPayload {
|
||||
readonly seat: number;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
function parseSeatPayload(value: unknown): ParsedSeatPayload {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
const seat = requireInteger(raw.seat, '$.data.seat');
|
||||
if (seat < 0) throw new RangeError('$.data.seat: expected non-negative server seat, received number');
|
||||
return { seat, raw };
|
||||
}
|
||||
|
||||
export const parsePlayerPreparePayload = parseSeatPayload;
|
||||
export const parseOtherJoinRoomPayload = parseSeatPayload;
|
||||
export const parseOtherExitRoomPayload = parseSeatPayload;
|
||||
export const parseOtherOfflinePayload = parseSeatPayload;
|
||||
|
||||
export interface ParsedOnlinePayload extends ParsedSeatPayload {
|
||||
readonly ip: string;
|
||||
}
|
||||
|
||||
export function parseOtherOnlinePayload(value: unknown): ParsedOnlinePayload {
|
||||
const parsed = parseSeatPayload(value);
|
||||
return { seat: parsed.seat, ip: requireString(parsed.raw.ip, '$.data.ip'), raw: parsed.raw };
|
||||
}
|
||||
|
||||
export interface ParsedRoomserverPayload {
|
||||
readonly roomserver: string;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseConnectRoomserverPayload(value: unknown): ParsedRoomserverPayload {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
return { roomserver: requireString(raw.roomserver, '$.data.roomserver'), raw };
|
||||
}
|
||||
|
||||
export interface ParsedAgentserverPayload {
|
||||
readonly agentserver: string;
|
||||
readonly opt: string;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseConnectAgentserverPayload(value: unknown): ParsedAgentserverPayload {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
return {
|
||||
agentserver: requireString(raw.agentserver, '$.data.agentserver'),
|
||||
opt: requireString(raw.opt, '$.data.opt'),
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ParsedKickPayload {
|
||||
readonly msg: string;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseKickServerPayload(value: unknown): ParsedKickPayload {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
return { msg: requireString(raw.msg, '$.data.msg'), raw };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export type JsonRecord = Readonly<Record<string, unknown>>;
|
||||
|
||||
function receivedType(value: unknown): string {
|
||||
if (value === null) return 'null';
|
||||
if (Array.isArray(value)) return 'array';
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function invalid(path: string, expected: string, value: unknown): never {
|
||||
throw new TypeError(`${path}: expected ${expected}, received ${receivedType(value)}`);
|
||||
}
|
||||
|
||||
export function hasOwn(record: JsonRecord, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(record, key);
|
||||
}
|
||||
|
||||
export function requireRecord(value: unknown, path: string): JsonRecord {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid(path, 'record', value);
|
||||
}
|
||||
return value as JsonRecord;
|
||||
}
|
||||
|
||||
export function requireString(value: unknown, path: string): string {
|
||||
if (typeof value !== 'string') return invalid(path, 'string', value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireNumber(value: unknown, path: string): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return invalid(path, 'finite number', value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function requireInteger(value: unknown, path: string): number {
|
||||
const number = requireNumber(value, path);
|
||||
if (!Number.isInteger(number)) return invalid(path, 'integer', value);
|
||||
return number;
|
||||
}
|
||||
|
||||
export function requireArray(value: unknown, path: string): readonly unknown[] {
|
||||
if (!Array.isArray(value)) return invalid(path, 'array', value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function optionalField<T>(
|
||||
record: JsonRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
validate: (value: unknown, path: string) => T,
|
||||
): T | undefined {
|
||||
if (!hasOwn(record, key)) return undefined;
|
||||
return validate(record[key], `${path}.${key}`);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { APP } from '../core/constants.ts';
|
||||
|
||||
export type FirstSliceRpc =
|
||||
| 'player_login'
|
||||
| 'self_join_room'
|
||||
| 'player_prepare'
|
||||
| 'self_exit_room'
|
||||
| 'other_join_room'
|
||||
| 'other_exit_room'
|
||||
| 'other_offline'
|
||||
| 'other_online'
|
||||
| 'connect_roomserver'
|
||||
| 'connect_agentserver'
|
||||
| 'kick_server';
|
||||
|
||||
export type FirstSliceRoute = 'platform' | 'agent' | 'room';
|
||||
|
||||
/** 第一纵向切片 rpc → route 的唯一权威映射。 */
|
||||
export const FIRST_SLICE_RPC_ROUTE = {
|
||||
player_login: 'agent',
|
||||
self_join_room: 'agent',
|
||||
player_prepare: 'room',
|
||||
self_exit_room: 'room',
|
||||
other_join_room: 'room',
|
||||
other_exit_room: 'room',
|
||||
other_offline: 'room',
|
||||
other_online: 'room',
|
||||
connect_roomserver: 'room',
|
||||
connect_agentserver: 'agent',
|
||||
kick_server: 'agent',
|
||||
} as const satisfies Record<FirstSliceRpc, 'platform' | 'agent' | 'room'>;
|
||||
|
||||
export function routeForFirstSlice<Rpc extends FirstSliceRpc>(
|
||||
rpc: Rpc,
|
||||
): (typeof FIRST_SLICE_RPC_ROUTE)[Rpc] {
|
||||
if (!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE, rpc)) {
|
||||
throw new Error(`FIRST_SLICE_RPC_ROUTE 未定义 rpc: ${String(rpc)}`);
|
||||
}
|
||||
return FIRST_SLICE_RPC_ROUTE[rpc];
|
||||
}
|
||||
|
||||
export interface FirstSliceEnvelope<
|
||||
Rpc extends FirstSliceRpc,
|
||||
Data extends Readonly<Record<string, unknown>>,
|
||||
> {
|
||||
readonly app: 'youle';
|
||||
readonly route: (typeof FIRST_SLICE_RPC_ROUTE)[Rpc];
|
||||
readonly rpc: Rpc;
|
||||
readonly data: Data;
|
||||
}
|
||||
|
||||
export function buildFirstSliceEnvelope<
|
||||
Rpc extends FirstSliceRpc,
|
||||
Data extends Readonly<Record<string, unknown>>,
|
||||
>(rpc: Rpc, data: Data): FirstSliceEnvelope<Rpc, Data> {
|
||||
return { app: APP, route: routeForFirstSlice(rpc), rpc, data };
|
||||
}
|
||||
Reference in New Issue
Block a user