feat: integrate room framework and isolated subgame bundles

Complete room UI and protocol integration, move game definitions and resources behind bundle entries, publish authoritative version XML, and document single-game builds. Include all current resource changes and experiment artifacts.
This commit is contained in:
2026-09-09 03:13:18 +08:00
parent a7ec8aa6a7
commit 35216f75e7
1028 changed files with 835556 additions and 21415 deletions
@@ -39,8 +39,19 @@ export function reconnectFrom(raw: JsonRecord): ReconnectSnapshot {
return { present: true, value: raw.deskinfo };
}
export function parseRoomEntryFields(raw: JsonRecord): ParsedRoomSnapshot {
const roomcode = requireString(raw.roomcode, '$.data.roomcode');
/** Wire room IDs may be numeric; internal identity remains a string. Never mutate raw packets. */
export function parseResponseRoomcode(value: unknown, path = '$.data.roomcode'): string {
if (typeof value === 'number') {
if (!Number.isSafeInteger(value) || value < 0) {
throw new TypeError(`${path}: expected string or non-negative safe integer`);
}
return String(value);
}
return requireString(value, path);
}
export function parseRoomEntryFields(raw: JsonRecord): ParsedRoomSnapshot {
const roomcode = parseResponseRoomcode(raw.roomcode);
const seat = requireInteger(raw.seat, '$.data.seat');
const roomtype = requireRoomtype(raw.roomtype, '$.data.roomtype');
const players = requireArray(raw.players, '$.data.players');
@@ -229,9 +240,12 @@ export interface ParsedAgentserverPayload {
export function parseConnectAgentserverPayload(value: unknown): ParsedAgentserverPayload {
const raw = requireRecord(value, '$.data');
// Server room/rpc.js emits the switch operation under data.data; older flat packets
// carry opt directly. Parse the explicit envelope variant once at this boundary.
const operation=raw.data===undefined?raw:requireRecord(raw.data,'$.data.data');
return {
agentserver: requireString(raw.agentserver, '$.data.agentserver'),
opt: requireString(raw.opt, '$.data.opt'),
opt: requireString(operation.opt, raw.data===undefined?'$.data.opt':'$.data.data.opt'),
raw,
};
}
@@ -296,7 +310,7 @@ export function parseCreateRoomResponse(value: unknown): ParsedCreateRoomRespons
};
return {
state,
room: {roomcode: requireString(raw.roomcode, '$.data.roomcode'),
room: {roomcode: parseResponseRoomcode(raw.roomcode),
seat: requireInteger(raw.seat, '$.data.seat'),
roomtype: requireRoomtype(raw.roomtype, '$.data.roomtype')},
needprepare: flag('needprepare'), infinite: flag('infinite'), raw,
@@ -70,6 +70,27 @@ export function optionalField<T>(
/** Validate the transport shape without interpreting game-specific options. */
export function requireRoomtype(value: unknown, path: string): Roomtype {
if (typeof value === 'string' || Array.isArray(value)) return value;
return invalid(path, 'string or array', value);
const active=new WeakSet<object>();
const visit=(item:unknown,at:string):void=>{
if(item===null||typeof item==='string'||typeof item==='boolean')return;
if(typeof item==='number'&&Number.isFinite(item))return;
if(typeof item!=='object'||item===null)return invalid(at,'finite JSON value',item);
if(active.has(item))throw new TypeError(at+': cyclic JSON value');
const array=Array.isArray(item);
if(!array&&Object.getPrototypeOf(item)!==Object.prototype&&Object.getPrototypeOf(item)!==null)return invalid(at,'plain JSON object',item);
active.add(item);
if(array&&Object.keys(item).length!==item.length)throw new TypeError(at+': sparse or extended array');
const keys=Reflect.ownKeys(item);
for(const key of keys){
if(array&&key==='length')continue;
const descriptor=Object.getOwnPropertyDescriptor(item,key)!;
if(typeof key!=='string'||!descriptor.enumerable||!('value' in descriptor))throw new TypeError(at+': expected JSON data property');
if(array&&(!/^(0|[1-9][0-9]*)$/.test(key)||Number(key)>=item.length))throw new TypeError(at+': expected array index');
visit(descriptor.value,at+'.'+key);
}
const finalKeys=Reflect.ownKeys(item);
if(keys.length!==finalKeys.length||keys.some((key,index)=>key!==finalKeys[index]))throw new TypeError(at+': unstable JSON own keys');
active.delete(item);
};
visit(value,path);return value as Roomtype;
}
@@ -13,7 +13,7 @@ export function parseVipRooms(payload:unknown):readonly VipRoom[]{
const playerList=requireArray(r.playerList,p+'.playerList').map((v,i)=>Object.freeze([...requireArray(v,`${p}.playerList[${i}]`)]));
const codes=r.roomcodes?requireRecord(r.roomcodes,p+'.roomcodes'):null;
const rawType=codes?requireRoomtype(codes.roomtype,p+'.roomcodes.roomtype'):null;
const roomcodes=codes?Object.freeze({roomtype:typeof rawType==='string'?rawType:Object.freeze([...(rawType as readonly unknown[])]),roomlist:Object.freeze(requireArray(codes.roomlist,p+'.roomcodes.roomlist').map((v,i)=>vipCode(v,`${p}.roomcodes.roomlist[${i}]`)))}):null;
const roomcodes=codes?Object.freeze({roomtype:rawType,roomlist:Object.freeze(requireArray(codes.roomlist,p+'.roomcodes.roomlist').map((v,i)=>vipCode(v,`${p}.roomcodes.roomlist[${i}]`)))}):null;
const deskState=roomcodes?requireArray(r.deskState,p+'.deskState'):[];
const seatCount=roomcodes?requireArray(r.seatCount,p+'.seatCount').map((v,i)=>vipCode(v,`${p}.seatCount[${i}]`)):[];
if(roomcodes&&[roomcodes.roomlist.length,deskState.length,seatCount.length].some(n=>n!==playerList.length))throw TypeError(p+': desk arrays must have matching lengths');
@@ -7,6 +7,13 @@ export function isLoginResponse(message:{route:string;rpc:string}):boolean{
}
export type FirstSliceRpc =
| 'self_makewar' | 'other_makewar'
| 'broadcast'
| 'beanroom_surrender'
| 'send_text' | 'send_voice' | 'send_phiz' | 'self_break_room' | 'other_break_room' | 'change_room' | 'change_seat'
| 'self_apply_free_room' | 'other_apply_free_room'
| 'self_agree_free_room' | 'other_agree_free_room'
| 'self_refuse_free_room' | 'other_refuse_free_room' | 'free_room'
| 'getVipRankList'
| 'get_treasurelist'
| 'topup_card'
@@ -37,6 +44,12 @@ export type FirstSliceRoute = 'platform' | 'agent' | 'room';
/** 第一纵向切片 rpc → route 的唯一权威映射。 */
export const FIRST_SLICE_RPC_ROUTE = {
self_makewar:'room',other_makewar:'room',
broadcast:'agent',
beanroom_surrender:'room',send_text:'room',send_voice:'room',send_phiz:'room',self_break_room:'room',other_break_room:'room',change_room:'room',change_seat:'room',
self_apply_free_room:'room',other_apply_free_room:'room',
self_agree_free_room:'room',other_agree_free_room:'room',
self_refuse_free_room:'room',other_refuse_free_room:'room',free_room:'room',
getVipRankList: 'agent',
get_treasurelist: 'agent',
topup_card: 'agent',
@@ -50,13 +50,69 @@ export class PlatformHandlers {
}
const rpc = message.rpc as FirstSliceRpc;
const expectedRoute = routeForFirstSlice(rpc);
if (message.route !== expectedRoute && !isLoginResponse(message) && !(rpc==='update_bean'&&message.route==='room')) {
if (message.route !== expectedRoute && !isLoginResponse(message)
&& !(rpc==='update_bean'&&message.route==='room')
&& !(rpc==='broadcast'&&message.route==='room')
&& !(rpc==='connect_agentserver'&&message.route==='room')
&& !(rpc==='free_room'&&message.route==='agent')) {
throw new Error(
`Platform rpc route mismatch: rpc=${rpc} expected=${expectedRoute} received=${message.route}`,
);
}
switch (rpc) {
case 'broadcast':{
const raw=requireRecord(message.data,'$.data');
// Protocol source explicitly defines omitted msgtype as immediate message (0).
const type=raw.msgtype===undefined?0:raw.msgtype;
const text=requireString(raw.msgcontent,'$.data.msgcontent');
if(type===1)this.options.store.addRoomBroadcast(text);
else if(type===0){
// ShowiMessage remains until dismissed; reuse the platform immediate popup.
if(!this.options.showMessage)throw Error('broadcast requires a message UI');
this.options.showMessage(text,0,'show_message');
}else throw Error('Unsupported broadcast msgtype');
break;
}
case 'beanroom_surrender':{
this.requireActiveGame(rpc);
const raw=requireRecord(message.data,'$.data');
const state=Number(raw.state);
if(!Number.isInteger(state)||raw.state===null||raw.state==='')throw Error('beanroom_surrender requires server state');
if(state===0){
if(!this.options.showMessage)throw Error('beanroom_surrender settlement requires subgame support');
this.options.showMessage('服务器已确认投降,当前子游戏尚未接入投降结算。',0,'show_message');
}else if(Number(raw.showerror)===1){
if(!this.options.showMessage)throw Error('beanroom_surrender error requires message UI');
this.options.showMessage(requireString(raw.error,'$.data.error'),0,'show_message');
}
break;
}
case 'send_text':case 'send_voice':case 'send_phiz':
this.requireActiveGame(rpc);this.options.store.applyRoomSocial(rpc,message.data);break;
case 'change_seat':this.requireActiveGame(rpc);this.options.store.changeSeats(message.data);break;
case 'self_break_room':case 'other_break_room':
this.options.session.handleBreakRoom(message.data);
if(rpc==='other_break_room'){
if(!this.options.showMessage)throw Error('other_break_room requires a message UI');
this.options.showMessage('房主已解散房间!',3000,'show_message');
}
break;
case 'self_apply_free_room':case 'other_apply_free_room':
case 'self_agree_free_room':case 'other_agree_free_room':
case 'self_refuse_free_room':case 'other_refuse_free_room':case 'free_room':{
this.requireActiveGame(rpc);
this.options.store.applyDissolution(rpc,message.data);
const result=this.options.store.getState().dissolution;
if(result?.kind==='result'&&result.freeNow){
this.options.session.handleDissolutionResult(result);
if(result.tips!==undefined){
if(!this.options.showMessage)throw Error('freeNow tips require a message UI');
this.options.showMessage(result.tips,Number(result.time),'show_message');
}
}
break;
}
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.
@@ -97,13 +153,19 @@ export class PlatformHandlers {
this.options.session.handleCreateRoom(message.data);
break;
case 'self_join_room':
this.options.session.handleSelfJoin(message.data);
this.options.session.handleSelfJoin(message.data,message);
break;
case 'self_makewar':case 'other_makewar':{
this.requireActiveGame(rpc);requireRecord(message.data,'$.data');
this.options.store.startRoom(rpc);
this.options.gameSession.publish({type:'room.started',sourceRpc:rpc,message});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 });
if(parsed.raw.deskwar===1)this.options.gameSession.publish({type:'room.started',sourceRpc:rpc,message});
break;
}
case 'self_exit_room': {
@@ -116,6 +178,7 @@ export class PlatformHandlers {
this.requireActiveGame(rpc);
this.options.store.playerJoin(parsed);
this.options.gameSession.publish({ type: 'room.player-joined', seat: parsed.seat });
if(parsed.raw.deskwar===1){this.options.store.startRoom('other_makewar');this.options.gameSession.publish({type:'room.started',sourceRpc:rpc,message});}
break;
}
case 'other_exit_room': {
@@ -152,9 +215,11 @@ export class PlatformHandlers {
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),
);
const raw=parsed.raw;
const nested=raw.data===undefined?undefined:requireRecord(raw.data,'$.data.data');
const switchConnection=()=>this.options.connection.switchServer(target,resend);
if(raw.opt==='free_room'||nested?.opt==='free_room')this.options.session.handleDissolutionServerSwitch(switchConnection);
else this.options.session.handleServerSwitch(switchConnection);
break;
}
case 'show_message': {