feat: integrate platform room entry, UI migration and erqiwang documentation

This commit is contained in:
2026-09-07 21:01:08 +08:00
parent 9c4510697c
commit 30a1b4114c
259 changed files with 84995 additions and 30201 deletions
@@ -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": {}
}
@@ -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;
}