feat: integrate platform room entry, UI migration and erqiwang documentation
This commit is contained in:
@@ -1,52 +1,56 @@
|
||||
export {
|
||||
hasOwn,
|
||||
optionalField,
|
||||
requireArray,
|
||||
requireBoolean,
|
||||
requireInteger,
|
||||
requireNumber,
|
||||
requireRecord,
|
||||
requireString,
|
||||
requireStringOrFiniteNumber,
|
||||
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,
|
||||
parseSelfExitRoomPayload,
|
||||
parseSelfJoinRoomResponse,
|
||||
type JoinRoomEnvironment,
|
||||
type JoinRoomPayload,
|
||||
type ParsedAgentserverPayload,
|
||||
type ParsedKickPayload,
|
||||
type ParsedOnlinePayload,
|
||||
type ParsedRoomEntry,
|
||||
type ParsedRoomResponse,
|
||||
type ParsedRoomserverPayload,
|
||||
type ParsedRoomSnapshot,
|
||||
type ParsedSelfExitPayload,
|
||||
type ParsedSeatPayload,
|
||||
type ReconnectSnapshot,
|
||||
type RoomActionPayload,
|
||||
type RoomIdentity,
|
||||
} from './room-contracts.ts';
|
||||
export {
|
||||
hasOwn,
|
||||
optionalField,
|
||||
requireArray,
|
||||
requireBoolean,
|
||||
requireInteger,
|
||||
requireNumber,
|
||||
requireRecord,
|
||||
requireString,
|
||||
requireStringOrFiniteNumber,
|
||||
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 {
|
||||
buildCreateRoomRequest,
|
||||
parseCreateRoomResponse,
|
||||
type ParsedCreateRoomResponse,
|
||||
type CreateRoomEnvironment,
|
||||
buildExitRoomRequest,
|
||||
buildJoinRoomRequest,
|
||||
buildPrepareRequest,
|
||||
parseConnectAgentserverPayload,
|
||||
parseConnectRoomserverPayload,
|
||||
parseKickServerPayload,
|
||||
parseOtherExitRoomPayload,
|
||||
parseOtherJoinRoomPayload,
|
||||
parseOtherOfflinePayload,
|
||||
parseOtherOnlinePayload,
|
||||
parsePlayerPreparePayload,
|
||||
parseSelfExitRoomPayload,
|
||||
parseSelfJoinRoomResponse,
|
||||
type JoinRoomEnvironment,
|
||||
type JoinRoomPayload,
|
||||
type ParsedAgentserverPayload,
|
||||
type ParsedKickPayload,
|
||||
type ParsedOnlinePayload,
|
||||
type ParsedRoomEntry,
|
||||
type ParsedRoomResponse,
|
||||
type ParsedRoomserverPayload,
|
||||
type ParsedRoomSnapshot,
|
||||
type ParsedSelfExitPayload,
|
||||
type ParsedSeatPayload,
|
||||
type ReconnectSnapshot,
|
||||
type RoomActionPayload,
|
||||
type RoomIdentity,
|
||||
} from './room-contracts.ts';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import {requireArray,requireRecord,requireString,requireStringOrFiniteNumber} from './validation.ts';
|
||||
export interface RankEntry {readonly name:string;readonly headimg:string;readonly playerid:string|number;readonly star:string|number;readonly signature:string;readonly notice?:string;readonly destwo?:string;}
|
||||
/** Server rank entries are consumed as sent. Only rankList1 config replaces # line breaks. */
|
||||
export function parseRankList(value:unknown,fromConfig=false):readonly RankEntry[]{
|
||||
return Object.freeze(requireArray(value,'rank list').map((item,index)=>{
|
||||
const p=`rank list[${index}]`,r=requireRecord(item,p);
|
||||
const name=requireString(r.name,p+'.name'),signature=r.signature?requireString(r.signature,p+'.signature'):'';
|
||||
return Object.freeze({name:fromConfig?configRankName(name):name,headimg:requireString(r.headimg,p+'.headimg'),playerid:requireStringOrFiniteNumber(r.playerid,p+'.playerid'),
|
||||
star:requireStringOrFiniteNumber(r.star,p+'.star'),signature:fromConfig?signature.replace(/#/g,'\n'):signature});
|
||||
}));
|
||||
}
|
||||
|
||||
/** Func.subString(name,16,true): Latin-1 counts one, other UTF-16 units two. */
|
||||
function configRankName(name:string):string {
|
||||
let length=0;
|
||||
for(let i=0;i<name.length;i++){
|
||||
length+=name.charCodeAt(i)>255?2:1;
|
||||
if(length>16)return name.slice(0,i)+'...';
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
export function parseVipRankList(value:unknown):readonly RankEntry[]{
|
||||
const raw=requireArray(value,'VIP rank list'),entries=parseRankList(raw);
|
||||
return Object.freeze(entries.map((entry,i)=>{const item=requireRecord(raw[i],'VIP rank entry');
|
||||
return Object.freeze({...entry,notice:item.notice?requireString(item.notice,'notice'):'',destwo:item.destwo?requireString(item.destwo,'destwo'):''});}));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "744452fb-2d7e-4a0e-9c49-2e6994f5df84",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
+302
-243
@@ -1,243 +1,302 @@
|
||||
import {
|
||||
buildFirstSliceEnvelope,
|
||||
type FirstSliceEnvelope,
|
||||
} from '../first-slice-routes.ts';
|
||||
import {
|
||||
hasOwn,
|
||||
optionalField,
|
||||
requireArray,
|
||||
requireInteger,
|
||||
requireNumber,
|
||||
requireRecord,
|
||||
requireString,
|
||||
requireStringOrFiniteNumber,
|
||||
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');
|
||||
return {
|
||||
agentid: requireStringOrFiniteNumber(
|
||||
identity.agentid,
|
||||
'$.runtimeConfig.identity.agentid',
|
||||
),
|
||||
playerid: requireInteger(roomRecord.playerid, '$.room.playerid'),
|
||||
gameid: requireStringOrFiniteNumber(identity.gameid, '$.runtimeConfig.identity.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'),
|
||||
};
|
||||
const vipMatch = optionalField(
|
||||
environmentRecord,
|
||||
'vipMatch',
|
||||
'$.environment',
|
||||
(value, path): 1 => {
|
||||
const parsed = requireNumber(value, path);
|
||||
if (parsed !== 1) {
|
||||
throw new RangeError(`${path}: expected number 1, received number`);
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
if (vipMatch !== undefined) data.vipMatch = vipMatch;
|
||||
|
||||
const matchId = optionalField(
|
||||
environmentRecord,
|
||||
'match_id',
|
||||
'$.environment',
|
||||
requireStringOrFiniteNumber,
|
||||
);
|
||||
if (matchId !== undefined) data.match_id = matchId;
|
||||
return buildFirstSliceEnvelope('self_join_room', data as JoinRoomPayload);
|
||||
}
|
||||
|
||||
export interface ParsedRoomResponse {
|
||||
readonly state: number;
|
||||
readonly room: ParsedRoomSnapshot | null;
|
||||
readonly reconnect: ReconnectSnapshot;
|
||||
readonly stage: 0 | 1;
|
||||
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;
|
||||
const parsedDeskwar = optionalField(raw, 'deskwar', '$.data', requireInteger);
|
||||
if (parsedDeskwar !== undefined && parsedDeskwar !== 0 && parsedDeskwar !== 1) {
|
||||
throw new RangeError('$.data.deskwar: expected protocol flag 0 or 1');
|
||||
}
|
||||
const reconnect = reconnectFrom(raw);
|
||||
const stage = reconnect.present ? 1 : parsedDeskwar ?? 0;
|
||||
return { state, room, reconnect, stage, 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 ParsedSelfExitPayload {
|
||||
readonly seat?: number;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseSelfExitRoomPayload(value: unknown): ParsedSelfExitPayload {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
const seat = optionalField(raw, 'seat', '$.data', requireInteger);
|
||||
if (seat !== undefined && seat < 0) {
|
||||
throw new RangeError('$.data.seat: expected non-negative server seat, received number');
|
||||
}
|
||||
return { ...(seat === undefined ? {} : { seat }), raw };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
import {
|
||||
buildFirstSliceEnvelope,
|
||||
type FirstSliceEnvelope,
|
||||
} from '../first-slice-routes.ts';
|
||||
import {
|
||||
hasOwn,
|
||||
optionalField,
|
||||
requireArray,
|
||||
requireInteger,
|
||||
requireNumber,
|
||||
requireRecord,
|
||||
requireString,
|
||||
requireStringOrFiniteNumber,
|
||||
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');
|
||||
return {
|
||||
agentid: requireStringOrFiniteNumber(
|
||||
identity.agentid,
|
||||
'$.runtimeConfig.identity.agentid',
|
||||
),
|
||||
playerid: requireInteger(roomRecord.playerid, '$.room.playerid'),
|
||||
gameid: requireStringOrFiniteNumber(identity.gameid, '$.runtimeConfig.identity.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 Omit<RoomActionPayload, 'roomcode'> {
|
||||
readonly roomcode: string | number;
|
||||
readonly location: unknown;
|
||||
readonly ip: string;
|
||||
readonly vipMatch?: 1;
|
||||
readonly match_id?: string | number;
|
||||
}
|
||||
|
||||
export function buildJoinRoomRequest(
|
||||
runtimeConfig: FirstSliceRuntimeConfig,
|
||||
room: { readonly playerid: number; readonly roomcode: string | number },
|
||||
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, {playerid: room.playerid, roomcode: String(room.roomcode)}),
|
||||
roomcode: requireStringOrFiniteNumber(room.roomcode, '$.room.roomcode'),
|
||||
location: environmentRecord.location,
|
||||
ip: requireString(environmentRecord.ip, '$.environment.ip'),
|
||||
};
|
||||
const vipMatch = optionalField(
|
||||
environmentRecord,
|
||||
'vipMatch',
|
||||
'$.environment',
|
||||
(value, path): 1 => {
|
||||
const parsed = requireNumber(value, path);
|
||||
if (parsed !== 1) {
|
||||
throw new RangeError(`${path}: expected number 1, received number`);
|
||||
}
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
if (vipMatch !== undefined) data.vipMatch = vipMatch;
|
||||
|
||||
const matchId = optionalField(
|
||||
environmentRecord,
|
||||
'match_id',
|
||||
'$.environment',
|
||||
requireStringOrFiniteNumber,
|
||||
);
|
||||
if (matchId !== undefined) data.match_id = matchId;
|
||||
return buildFirstSliceEnvelope('self_join_room', data as JoinRoomPayload);
|
||||
}
|
||||
|
||||
export interface ParsedRoomResponse {
|
||||
readonly state: number;
|
||||
readonly room: ParsedRoomSnapshot | null;
|
||||
readonly reconnect: ReconnectSnapshot;
|
||||
readonly stage: 0 | 1;
|
||||
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;
|
||||
const parsedDeskwar = optionalField(raw, 'deskwar', '$.data', requireInteger);
|
||||
if (parsedDeskwar !== undefined && parsedDeskwar !== 0 && parsedDeskwar !== 1) {
|
||||
throw new RangeError('$.data.deskwar: expected protocol flag 0 or 1');
|
||||
}
|
||||
const reconnect = reconnectFrom(raw);
|
||||
const stage = reconnect.present ? 1 : parsedDeskwar ?? 0;
|
||||
return { state, room, reconnect, stage, 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 ParsedSelfExitPayload {
|
||||
readonly seat?: number;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseSelfExitRoomPayload(value: unknown): ParsedSelfExitPayload {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
const seat = optionalField(raw, 'seat', '$.data', requireInteger);
|
||||
if (seat !== undefined && seat < 0) {
|
||||
throw new RangeError('$.data.seat: expected non-negative server seat, received number');
|
||||
}
|
||||
return { ...(seat === undefined ? {} : { seat }), raw };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
export interface CreateRoomEnvironment {
|
||||
readonly roomtype: readonly unknown[];
|
||||
readonly location: unknown;
|
||||
readonly ip: string;
|
||||
}
|
||||
|
||||
/** Transport contract only: roomtype contents belong to the subgame. */
|
||||
export function buildCreateRoomRequest(
|
||||
runtimeConfig: FirstSliceRuntimeConfig,
|
||||
playerid: number,
|
||||
command: CreateRoomEnvironment,
|
||||
): FirstSliceEnvelope<'create_room', Readonly<Record<string, unknown>>> {
|
||||
const identity = requireRecord(runtimeConfig.identity, '$.runtimeConfig.identity');
|
||||
const raw = requireRecord(command, '$.command');
|
||||
if (!hasOwn(raw, 'location') || raw.location === undefined) {
|
||||
throw new TypeError('$.command.location: expected present field');
|
||||
}
|
||||
if (raw.location !== null) requireRecord(raw.location, '$.command.location');
|
||||
return buildFirstSliceEnvelope('create_room', {
|
||||
agentid: requireStringOrFiniteNumber(identity.agentid, '$.identity.agentid'),
|
||||
playerid: requireInteger(playerid, '$.playerid'),
|
||||
gameid: requireStringOrFiniteNumber(identity.gameid, '$.identity.gameid'),
|
||||
roomtype: requireArray(raw.roomtype, '$.command.roomtype'),
|
||||
ip: requireString(raw.ip, '$.command.ip'),
|
||||
location: raw.location,
|
||||
});
|
||||
}
|
||||
|
||||
export interface ParsedCreateRoomResponse {
|
||||
readonly state: number;
|
||||
readonly room: { readonly roomcode: string; readonly seat: number; readonly roomtype: readonly unknown[] } | null;
|
||||
readonly needprepare: number;
|
||||
readonly infinite: number;
|
||||
readonly raw: JsonRecord;
|
||||
}
|
||||
|
||||
export function parseCreateRoomResponse(value: unknown): ParsedCreateRoomResponse {
|
||||
const raw = requireRecord(value, '$.data');
|
||||
const state = requireInteger(raw.state, '$.data.state');
|
||||
if (state !== 0) return {state, room: null, needprepare: 0, infinite: 0, raw};
|
||||
// Desk.Init initializes these flags to zero; create_room overrides only when provided.
|
||||
const flag = (key: string): number => {
|
||||
if (!hasOwn(raw, key)) return 0;
|
||||
const value = requireInteger(raw[key], `$.data.${key}`);
|
||||
if (value !== 0 && value !== 1) throw new RangeError(`$.data.${key}: expected protocol flag`);
|
||||
return value;
|
||||
};
|
||||
return {
|
||||
state,
|
||||
room: {roomcode: requireString(raw.roomcode, '$.data.roomcode'),
|
||||
seat: requireInteger(raw.seat, '$.data.seat'),
|
||||
roomtype: requireArray(raw.roomtype, '$.data.roomtype')},
|
||||
needprepare: flag('needprepare'), infinite: flag('infinite'), raw,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,77 +1,91 @@
|
||||
import { APP } from '../core/constants.ts';
|
||||
|
||||
/** Legacy 12_Logic dispatches login replies from all three platform routes by rpc.
|
||||
* This inbound contract is independent of the outbound agent route below. */
|
||||
export function isLoginResponse(message:{route:string;rpc:string}):boolean{
|
||||
return message.rpc==='player_login'&&(message.route==='agent'||message.route==='room'||message.route==='platform');
|
||||
}
|
||||
|
||||
export type FirstSliceRpc =
|
||||
| 'getInfoByShortCode'
|
||||
| 'get_player_task'
|
||||
| 'get_task_award'
|
||||
| 'update_roomcard'
|
||||
| 'player_finish_task'
|
||||
| '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'
|
||||
| 'kick_offline'
|
||||
| 'show_message';
|
||||
|
||||
export type FirstSliceRoute = 'platform' | 'agent' | 'room';
|
||||
|
||||
/** 第一纵向切片 rpc → route 的唯一权威映射。 */
|
||||
export const FIRST_SLICE_RPC_ROUTE = {
|
||||
getInfoByShortCode: 'agent',
|
||||
get_player_task: 'agent',
|
||||
get_task_award: 'agent',
|
||||
update_roomcard: 'agent',
|
||||
player_finish_task: 'agent',
|
||||
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',
|
||||
kick_offline: 'agent',
|
||||
show_message: '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 };
|
||||
}
|
||||
import { APP } from '../core/constants.ts';
|
||||
|
||||
/** Legacy 12_Logic dispatches login replies from all three platform routes by rpc.
|
||||
* This inbound contract is independent of the outbound agent route below. */
|
||||
export function isLoginResponse(message:{route:string;rpc:string}):boolean{
|
||||
return message.rpc==='player_login'&&(message.route==='agent'||message.route==='room'||message.route==='platform');
|
||||
}
|
||||
|
||||
export type FirstSliceRpc =
|
||||
| 'getVipRankList'
|
||||
| 'get_treasurelist'
|
||||
| 'topup_card'
|
||||
| 'get_player_invitecode'
|
||||
| 'binding_invitecode'
|
||||
| 'getInfoByShortCode'
|
||||
| 'get_player_task'
|
||||
| 'get_task_award'
|
||||
| 'update_bean'
|
||||
| 'update_roomcard'
|
||||
| 'player_finish_task'
|
||||
| 'player_login'
|
||||
| 'create_room'
|
||||
| 'self_join_room'
|
||||
| 'player_prepare'
|
||||
| 'self_exit_room'
|
||||
| 'other_join_room'
|
||||
| 'other_exit_room'
|
||||
| 'other_offline'
|
||||
| 'other_online'
|
||||
| 'connect_roomserver'
|
||||
| 'connect_agentserver'
|
||||
| 'kick_server'
|
||||
| 'kick_offline'
|
||||
| 'show_message';
|
||||
|
||||
export type FirstSliceRoute = 'platform' | 'agent' | 'room';
|
||||
|
||||
/** 第一纵向切片 rpc → route 的唯一权威映射。 */
|
||||
export const FIRST_SLICE_RPC_ROUTE = {
|
||||
getVipRankList: 'agent',
|
||||
get_treasurelist: 'agent',
|
||||
topup_card: 'agent',
|
||||
get_player_invitecode: 'agent',
|
||||
binding_invitecode: 'agent',
|
||||
getInfoByShortCode: 'agent',
|
||||
get_player_task: 'agent',
|
||||
get_task_award: 'agent',
|
||||
update_bean: 'agent',
|
||||
update_roomcard: 'agent',
|
||||
player_finish_task: 'agent',
|
||||
player_login: 'agent',
|
||||
create_room: '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',
|
||||
kick_offline: 'agent',
|
||||
show_message: '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 };
|
||||
}
|
||||
|
||||
@@ -1,206 +1,228 @@
|
||||
import type { InboundMessage } from '../core/types/envelope.ts';
|
||||
import type { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
|
||||
import type { ConnectionControlPort } from '../platform/connection-intent.ts';
|
||||
import type { RuntimeSession } from '../platform/runtime-session.ts';
|
||||
import type { PlatformStore } from '../platform/stores/platform-store.ts';
|
||||
import { requireRecord, requireString } from './contracts/validation.ts';
|
||||
import {
|
||||
parseConnectAgentserverPayload,
|
||||
parseConnectRoomserverPayload,
|
||||
parseKickServerPayload,
|
||||
parseOtherExitRoomPayload,
|
||||
parseOtherJoinRoomPayload,
|
||||
parseOtherOfflinePayload,
|
||||
parseOtherOnlinePayload,
|
||||
parsePlayerPreparePayload,
|
||||
parseSelfExitRoomPayload,
|
||||
} from './contracts/index.ts';
|
||||
import {
|
||||
buildFirstSliceEnvelope,
|
||||
isLoginResponse,
|
||||
FIRST_SLICE_RPC_ROUTE,
|
||||
routeForFirstSlice,
|
||||
type FirstSliceRpc,
|
||||
} from './first-slice-routes.ts';
|
||||
|
||||
export interface PlatformHandlersOptions {
|
||||
readonly store: PlatformStore;
|
||||
readonly gameSession: GameSessionHost;
|
||||
readonly session: RuntimeSession;
|
||||
readonly connection: ConnectionControlPort;
|
||||
readonly showMessage?: (message:string,time:number,source:'show_message'|'update_roomcard')=>void;
|
||||
}
|
||||
|
||||
export type PlatformDispatchResult = 'handled-platform';
|
||||
|
||||
/** Strict first-slice platform RPC dispatcher. */
|
||||
export class PlatformHandlers {
|
||||
constructor(private readonly options: PlatformHandlersOptions) {}
|
||||
|
||||
dispatch(message: InboundMessage): PlatformDispatchResult {
|
||||
if (!isPlatformRoute(message.route)) {
|
||||
throw new Error(
|
||||
`PlatformHandlers received non-platform route: route=${message.route} rpc=${message.rpc}`,
|
||||
);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE, message.rpc)) {
|
||||
throw new Error(
|
||||
`Unsupported platform rpc: route=${message.route} rpc=${message.rpc}`,
|
||||
);
|
||||
}
|
||||
const rpc = message.rpc as FirstSliceRpc;
|
||||
const expectedRoute = routeForFirstSlice(rpc);
|
||||
if (message.route !== expectedRoute && !isLoginResponse(message)) {
|
||||
throw new Error(
|
||||
`Platform rpc route mismatch: rpc=${rpc} expected=${expectedRoute} received=${message.route}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (rpc) {
|
||||
case 'getInfoByShortCode':this.options.store.applyVipRooms(message.data);break;
|
||||
case 'update_roomcard': {
|
||||
const raw=requireRecord(message.data,'$.data');
|
||||
const text=raw.text?requireString(raw.text,'$.data.text'):null;
|
||||
this.options.store.applyRoomcardUpdate(raw);
|
||||
if(raw.change===undefined&&text){
|
||||
if(!this.options.showMessage)throw Error('update_roomcard requires a message UI');
|
||||
this.options.showMessage(text,0,'update_roomcard');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'get_player_task':this.options.store.applyTaskList(message.data);break;
|
||||
case 'get_task_award':this.options.store.applyTaskAward(message.data);break;
|
||||
case 'player_finish_task':this.options.store.applyTaskFinished(message.data);break;
|
||||
case 'player_login':
|
||||
this.options.session.handleLogin(message.data);
|
||||
break;
|
||||
case 'self_join_room':
|
||||
this.options.session.handleSelfJoin(message.data);
|
||||
break;
|
||||
case 'player_prepare': {
|
||||
const parsed = parsePlayerPreparePayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerReady(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-ready', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'self_exit_room': {
|
||||
const parsed = parseSelfExitRoomPayload(message.data);
|
||||
this.options.session.handleSelfExit(parsed);
|
||||
break;
|
||||
}
|
||||
case 'other_join_room': {
|
||||
const parsed = parseOtherJoinRoomPayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerJoin(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-joined', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'other_exit_room': {
|
||||
const parsed = parseOtherExitRoomPayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerExit(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-left', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'other_offline': {
|
||||
const parsed = parseOtherOfflinePayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerOffline(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-offline', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'other_online': {
|
||||
const parsed = parseOtherOnlinePayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerOnline(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-online', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'connect_roomserver': {
|
||||
const parsed = parseConnectRoomserverPayload(message.data);
|
||||
const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver');
|
||||
const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw);
|
||||
this.options.session.handleServerSwitch(
|
||||
() => this.options.connection.switchServer(target, resend),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'connect_agentserver': {
|
||||
const parsed = parseConnectAgentserverPayload(message.data);
|
||||
const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver');
|
||||
const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw);
|
||||
this.options.session.handleServerSwitch(
|
||||
() => this.options.connection.switchServer(target, resend),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'show_message': {
|
||||
const data=requireRecord(message.data,'$.data');
|
||||
const text=requireString(data.msg,'$.data.msg');
|
||||
// Desk.show_message converts the original time with Number; only >0 auto-closes.
|
||||
const time=Number(data.time);
|
||||
if(!this.options.showMessage)throw Error('show_message requires a message UI');
|
||||
this.options.showMessage(text,time,'show_message');
|
||||
break;
|
||||
}
|
||||
case 'kick_offline': {
|
||||
const data=requireRecord(message.data,'$.data');
|
||||
this.options.session.handleKick(data,()=>this.options.connection.stopForKick(data));
|
||||
break;
|
||||
}
|
||||
case 'kick_server': {
|
||||
const parsed = parseKickServerPayload(message.data);
|
||||
this.options.session.handleKick(
|
||||
parsed.raw,
|
||||
() => this.options.connection.stopForKick(parsed.raw),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return 'handled-platform';
|
||||
}
|
||||
|
||||
private requireActiveGame(rpc: string): void {
|
||||
if (this.options.gameSession.state !== 'active') {
|
||||
throw new Error(`${rpc} requires an active game session`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {
|
||||
return route === 'platform' || route === 'agent' || route === 'room';
|
||||
}
|
||||
|
||||
function parseSwitchTarget(address: string, path: string): string {
|
||||
if (address.length === 0 || address.trim() !== address) {
|
||||
throw new TypeError(`${path}: expected a non-empty WebSocket server address`);
|
||||
}
|
||||
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address);
|
||||
const target = scheme ? address : `ws://${address}`;
|
||||
const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(target)?.[1];
|
||||
if (authority === undefined || authority.length === 0) {
|
||||
throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`);
|
||||
}
|
||||
if (target.includes('#') || authority.includes('@')) {
|
||||
throw new TypeError(`${path}: expected a WebSocket address without fragment or userinfo`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(target);
|
||||
} catch {
|
||||
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
||||
}
|
||||
const protocol = parsed.protocol.toLowerCase();
|
||||
if (
|
||||
(protocol !== 'ws:' && protocol !== 'wss:')
|
||||
|| parsed.hostname.length === 0
|
||||
|| parsed.username.length > 0
|
||||
|| parsed.password.length > 0
|
||||
|| parsed.hash.length > 0
|
||||
) {
|
||||
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
import type { InboundMessage } from '../core/types/envelope.ts';
|
||||
import type { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
|
||||
import type { ConnectionControlPort } from '../platform/connection-intent.ts';
|
||||
import type { RuntimeSession } from '../platform/runtime-session.ts';
|
||||
import type { PlatformStore } from '../platform/stores/platform-store.ts';
|
||||
import { requireRecord, requireString } from './contracts/validation.ts';
|
||||
import {
|
||||
parseConnectAgentserverPayload,
|
||||
parseConnectRoomserverPayload,
|
||||
parseKickServerPayload,
|
||||
parseOtherExitRoomPayload,
|
||||
parseOtherJoinRoomPayload,
|
||||
parseOtherOfflinePayload,
|
||||
parseOtherOnlinePayload,
|
||||
parsePlayerPreparePayload,
|
||||
parseSelfExitRoomPayload,
|
||||
} from './contracts/index.ts';
|
||||
import {
|
||||
buildFirstSliceEnvelope,
|
||||
isLoginResponse,
|
||||
FIRST_SLICE_RPC_ROUTE,
|
||||
routeForFirstSlice,
|
||||
type FirstSliceRpc,
|
||||
} from './first-slice-routes.ts';
|
||||
|
||||
export interface PlatformHandlersOptions {
|
||||
readonly store: PlatformStore;
|
||||
readonly gameSession: GameSessionHost;
|
||||
readonly session: RuntimeSession;
|
||||
readonly connection: ConnectionControlPort;
|
||||
readonly showMessage?: (message:string,time:number,source:'show_message'|'update_roomcard'|'update_bean'|'binding_invitecode')=>void;
|
||||
}
|
||||
|
||||
export type PlatformDispatchResult = 'handled-platform';
|
||||
|
||||
/** Strict first-slice platform RPC dispatcher. */
|
||||
export class PlatformHandlers {
|
||||
constructor(private readonly options: PlatformHandlersOptions) {}
|
||||
|
||||
dispatch(message: InboundMessage): PlatformDispatchResult {
|
||||
if (!isPlatformRoute(message.route)) {
|
||||
throw new Error(
|
||||
`PlatformHandlers received non-platform route: route=${message.route} rpc=${message.rpc}`,
|
||||
);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE, message.rpc)) {
|
||||
throw new Error(
|
||||
`Unsupported platform rpc: route=${message.route} rpc=${message.rpc}`,
|
||||
);
|
||||
}
|
||||
const rpc = message.rpc as FirstSliceRpc;
|
||||
const expectedRoute = routeForFirstSlice(rpc);
|
||||
if (message.route !== expectedRoute && !isLoginResponse(message) && !(rpc==='update_bean'&&message.route==='room')) {
|
||||
throw new Error(
|
||||
`Platform rpc route mismatch: rpc=${rpc} expected=${expectedRoute} received=${message.route}`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (rpc) {
|
||||
case 'getVipRankList':this.options.store.applyVipRank(message.data);break;
|
||||
case 'get_treasurelist':this.options.store.applyTreasureList(message.data);break;
|
||||
case 'topup_card':break; // Original response is empty; updates arrive separately.
|
||||
case 'get_player_invitecode':this.options.store.applyInvitecode(message.data);break;
|
||||
case 'binding_invitecode':{
|
||||
if(!this.options.showMessage)throw Error('binding_invitecode requires a message UI');
|
||||
const text=this.options.store.applyInviteBinding(message.data);
|
||||
this.options.showMessage(text,3000,'binding_invitecode');break;
|
||||
}
|
||||
case 'getInfoByShortCode':this.options.store.applyVipRooms(message.data);break;
|
||||
case 'update_bean': {
|
||||
const raw=requireRecord(message.data,'$.data');
|
||||
const text=raw.text?requireString(raw.text,'$.data.text'):null;
|
||||
this.options.store.applyBeanUpdate(raw);
|
||||
if(raw.change===undefined&&raw.seat===undefined&&text){
|
||||
if(!this.options.showMessage)throw Error('update_bean requires a message UI');
|
||||
this.options.showMessage(text,0,'update_bean');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'update_roomcard': {
|
||||
const raw=requireRecord(message.data,'$.data');
|
||||
const text=raw.text?requireString(raw.text,'$.data.text'):null;
|
||||
this.options.store.applyRoomcardUpdate(raw);
|
||||
if(raw.change===undefined&&text){
|
||||
if(!this.options.showMessage)throw Error('update_roomcard requires a message UI');
|
||||
this.options.showMessage(text,0,'update_roomcard');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'get_player_task':this.options.store.applyTaskList(message.data);break;
|
||||
case 'get_task_award':this.options.store.applyTaskAward(message.data);break;
|
||||
case 'player_finish_task':this.options.store.applyTaskFinished(message.data);break;
|
||||
case 'player_login':
|
||||
this.options.session.handleLogin(message.data);
|
||||
break;
|
||||
case 'create_room':
|
||||
this.options.session.handleCreateRoom(message.data);
|
||||
break;
|
||||
case 'self_join_room':
|
||||
this.options.session.handleSelfJoin(message.data);
|
||||
break;
|
||||
case 'player_prepare': {
|
||||
const parsed = parsePlayerPreparePayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerReady(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-ready', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'self_exit_room': {
|
||||
const parsed = parseSelfExitRoomPayload(message.data);
|
||||
this.options.session.handleSelfExit(parsed);
|
||||
break;
|
||||
}
|
||||
case 'other_join_room': {
|
||||
const parsed = parseOtherJoinRoomPayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerJoin(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-joined', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'other_exit_room': {
|
||||
const parsed = parseOtherExitRoomPayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerExit(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-left', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'other_offline': {
|
||||
const parsed = parseOtherOfflinePayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerOffline(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-offline', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'other_online': {
|
||||
const parsed = parseOtherOnlinePayload(message.data);
|
||||
this.requireActiveGame(rpc);
|
||||
this.options.store.playerOnline(parsed);
|
||||
this.options.gameSession.publish({ type: 'room.player-online', seat: parsed.seat });
|
||||
break;
|
||||
}
|
||||
case 'connect_roomserver': {
|
||||
const parsed = parseConnectRoomserverPayload(message.data);
|
||||
const target = parseSwitchTarget(parsed.roomserver, '$.data.roomserver');
|
||||
const resend = buildFirstSliceEnvelope('connect_roomserver', parsed.raw);
|
||||
this.options.session.handleServerSwitch(
|
||||
() => this.options.connection.switchServer(target, resend),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'connect_agentserver': {
|
||||
const parsed = parseConnectAgentserverPayload(message.data);
|
||||
const target = parseSwitchTarget(parsed.agentserver, '$.data.agentserver');
|
||||
const resend = buildFirstSliceEnvelope('connect_agentserver', parsed.raw);
|
||||
this.options.session.handleServerSwitch(
|
||||
() => this.options.connection.switchServer(target, resend),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'show_message': {
|
||||
const data=requireRecord(message.data,'$.data');
|
||||
const text=requireString(data.msg,'$.data.msg');
|
||||
// Desk.show_message converts the original time with Number; only >0 auto-closes.
|
||||
const time=Number(data.time);
|
||||
if(!this.options.showMessage)throw Error('show_message requires a message UI');
|
||||
this.options.showMessage(text,time,'show_message');
|
||||
break;
|
||||
}
|
||||
case 'kick_offline': {
|
||||
const data=requireRecord(message.data,'$.data');
|
||||
this.options.session.handleKick(data,()=>this.options.connection.stopForKick(data));
|
||||
break;
|
||||
}
|
||||
case 'kick_server': {
|
||||
const parsed = parseKickServerPayload(message.data);
|
||||
this.options.session.handleKick(
|
||||
parsed.raw,
|
||||
() => this.options.connection.stopForKick(parsed.raw),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return 'handled-platform';
|
||||
}
|
||||
|
||||
private requireActiveGame(rpc: string): void {
|
||||
if (this.options.gameSession.state !== 'active') {
|
||||
throw new Error(`${rpc} requires an active game session`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isPlatformRoute(route: string): route is 'platform' | 'agent' | 'room' {
|
||||
return route === 'platform' || route === 'agent' || route === 'room';
|
||||
}
|
||||
|
||||
function parseSwitchTarget(address: string, path: string): string {
|
||||
if (address.length === 0 || address.trim() !== address) {
|
||||
throw new TypeError(`${path}: expected a non-empty WebSocket server address`);
|
||||
}
|
||||
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(address);
|
||||
const target = scheme ? address : `ws://${address}`;
|
||||
const authority = /^[A-Za-z][A-Za-z0-9+.-]*:\/\/([^/?#]*)/.exec(target)?.[1];
|
||||
if (authority === undefined || authority.length === 0) {
|
||||
throw new TypeError(`${path}: expected a WebSocket address with a non-empty host`);
|
||||
}
|
||||
if (target.includes('#') || authority.includes('@')) {
|
||||
throw new TypeError(`${path}: expected a WebSocket address without fragment or userinfo`);
|
||||
}
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(target);
|
||||
} catch {
|
||||
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
||||
}
|
||||
const protocol = parsed.protocol.toLowerCase();
|
||||
if (
|
||||
(protocol !== 'ws:' && protocol !== 'wss:')
|
||||
|| parsed.hostname.length === 0
|
||||
|| parsed.username.length > 0
|
||||
|| parsed.password.length > 0
|
||||
|| parsed.hash.length > 0
|
||||
) {
|
||||
throw new TypeError(`${path}: expected a valid WebSocket server address`);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user