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
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "647a7b22-853c-4083-b6e9-c287ccc6babc",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "e4f0c9ce-255e-463b-b907-bec24412c756",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,61 @@
import {createBuildIdentity} from './build-identity.ts';
import {_decorator,Prefab,SceneAsset,Node,assetManager,AssetManager,TextAsset,js} from 'cc';
import versionManifestParser,{type VersionManifest} from './version-manifest.js';
import {GameComposition,type CommonRoomView} from '../../scripts/platform-login/GameComposition.ts';
import {getGameDefinition,gameBundleName,gameResourcePath,type SubgameEntry} from './game-definitions.ts';
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
const {ccclass,property}=_decorator;
/** App composition root: each game assigns its own pre-room page, room view and scene. */
@ccclass('SubgameAssets')
export class SubgameAssets extends GameComposition {
@property gameKey:string='';
private loaded:Readonly<{definition:GameDefinition;createRoom:Prefab;room:Prefab}>|null=null;
get createRoom(){return this.loaded?.createRoom??null;}
get room(){return this.loaded?.room??null;}
@property(SceneAsset) roomScene:SceneAsset|null=null;
private manifestValue:Readonly<VersionManifest>|null=null;
get versionManifest(){return this.manifestValue;}
get displayVersion(){if(!this.manifestValue)throw Error('version.xml has not been loaded');return this.manifestValue.version;}
async loadBuildIdentity(){
await this.loadGameAssets();
const key=this.gameKey,path=this.definition.config.versionResource;
if(path!==key+'/version')throw Error('Version resource does not match selected game: '+key);
const bundleName='version-'+key;
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(bundleName,(error,result)=>{
if(error)reject(Error('Cannot load '+bundleName+': '+error.message));
else if(!result)reject(Error('Missing '+bundleName));else resolve(result);
}));
const asset=await new Promise<TextAsset>((resolve,reject)=>bundle.load(path,TextAsset,(error,result)=>{
if(error)reject(Error('Cannot load '+path+'.xml: '+error.message));
else if(!result)reject(Error('Missing '+path+'.xml'));else resolve(result);
}));
try {
if(this.gameKey!==key)throw Error('Game selection changed during version.xml load');
const manifest=versionManifestParser.parseVersionManifest(asset.text);
const identity=createBuildIdentity(manifest);
this.manifestValue=manifest;
return identity;
} finally {bundle.release(path,TextAsset);}
}
private async loadGameAssets(){
const key=this.gameKey;
if(this.loaded){if(this.loaded.definition.key!==key)throw Error('Game selection changed after loading');return;}
const bundle=await new Promise<AssetManager.Bundle>((resolve,reject)=>assetManager.loadBundle(gameBundleName(key),(error,result)=>{
if(error)reject(error);else if(!result)reject(Error('Missing game bundle: '+key));else resolve(result);
}));
const definition=getGameDefinition(key,name=>js.getClassByName(name) as unknown as SubgameEntry|null);
const load=(resource:string)=>new Promise<Prefab>((resolve,reject)=>bundle.load(gameResourcePath(definition.config.assetRoot,resource),Prefab,(error,result)=>{
if(error)reject(error);else if(!result)reject(Error('Missing game prefab: '+resource));else resolve(result);
}));
const [createRoom,room]=await Promise.all([load(definition.resources.createRoom),load(definition.resources.room)]);
if(this.gameKey!==key)throw Error('Game selection changed during asset load');
this.loaded={definition,createRoom,room};
}
get definition(){if(!this.loaded)throw Error('Subgame assets have not been loaded');return this.loaded.definition;}
resolveRoomView(root:Node):CommonRoomView {
const name=this.definition.roomViewClass;
const view=root.getComponent(name) as (CommonRoomView|null);
if(!view||typeof view.render!=='function'||typeof view.close!=='function'||typeof view.dissolutionReceived!=='function')throw Error(`Room prefab missing valid ${name}`);
return view;
}
}
@@ -0,0 +1,8 @@
import {resolveIdentity,type ChannelIdentity} from '../../framework/config/identity.ts';
import type {VersionManifest} from './version-manifest.js';
/** XML has no market field. This application value is for Web testing only. */
export const WEB_MARKET_ID=4;
export function createBuildIdentity(manifest:VersionManifest):Readonly<ChannelIdentity> {
return resolveIdentity([()=>({agentid:manifest.agentid,channelid:manifest.channelid,
gameid:manifest.gameid,version:manifest.versionCode,marketid:WEB_MARKET_ID})]);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "b1825c2d-fa8a-4bd5-b20d-cad54a388938",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,19 @@
import type {GameDefinition} from '../../framework/sdk/contracts/game-definition.ts';
export interface SubgameEntry {readonly definition:GameDefinition;}
export function gameBundleName(key:string):string {
if(!/^[A-Za-z0-9_-]+$/.test(key))throw Error('Invalid gameKey: '+key);
return 'game-'+key;
}
/** Resolve only after the selected bundle has registered its entry class. */
export function getGameDefinition(key:string,findEntry:(name:string)=>SubgameEntry|null):GameDefinition {
gameBundleName(key);
const entry=findEntry('GameEntry_'+key);
if(!entry?.definition)throw Error('Missing subgame entry: '+key);
if(entry.definition.key!==key)throw Error('Subgame entry key mismatch: '+key);
return entry.definition;
}
export function gameResourcePath(root:string,resource:string):string {
if(!/^games\/[A-Za-z0-9_-]+$/.test(root)||!resource.startsWith(root+'/')||!resource.endsWith('.prefab')||resource.split('/').some(part=>!part||part==='.'||part==='..')||resource.includes('\\'))throw Error('Invalid private game resource: '+resource);
return resource.slice(root.length+1,-'.prefab'.length);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8d097469-ffe1-48e5-9e8f-eb8d7c6b8774",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,7 @@
export interface VersionManifest {
readonly agentid:string;readonly agentName:string;readonly channelid:string;readonly channelName:string;
readonly gameid:string;readonly gameName:string;readonly versionCode:number;readonly version:string;
}
export function parseVersionManifest(xml:string):Readonly<VersionManifest>;
declare const parser:{parseVersionManifest:typeof parseVersionManifest};
export default parser;
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4bbb1366-4f50-4d7b-9a67-2f163b4c20c1",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,28 @@
'use strict';
// Shared by Cocos runtime and the build extension: one parser for the native contract.
const {DOMParser}=require('@xmldom/xmldom');
function parseVersionManifest(xml){
const fail=message=>{throw Error('version.xml: '+message);};
if(typeof xml!=='string'||!xml.trim())fail('empty document');
if(/<!DOCTYPE|<!ENTITY/i.test(xml))fail('DTD and entity declarations are unsupported');
let doc;
try {doc=new DOMParser({onError:(_level,message)=>fail(message)}).parseFromString(xml,'text/xml');}
catch(error){fail(error.message);}
const root=doc.documentElement;
if(!root||root.tagName!=='game'||root.namespaceURI)fail('expected game root');
const fields={};
for(let child=root.firstChild;child;child=child.nextSibling){
if(child.nodeType===3&&!child.nodeValue.trim()||child.nodeType===8)continue;
if(child.nodeType!==1||!['agent','game','channel','version'].includes(child.tagName)||child.namespaceURI)fail('unexpected child');
if(fields[child.tagName])fail('duplicate '+child.tagName);
fields[child.tagName]=child;
for(let nested=child.firstChild;nested;nested=nested.nextSibling){if(nested.nodeType!==3||nested.nodeValue.trim())fail('unexpected nested content');}
}
const attr=(tag,name)=>{const node=fields[tag];if(!node)fail('missing '+tag);const value=node.getAttribute(name);if(value===null||!value.trim())fail('missing '+tag+'@'+name);return value;};
const code=attr('version','value');
if(!/^\d+$/.test(code)||!Number.isSafeInteger(Number(code)))fail('version@value must be a nonnegative safe integer');
return Object.freeze({agentid:attr('agent','id'),agentName:attr('agent','name'),
gameid:attr('game','id'),gameName:attr('game','name'),channelid:attr('channel','id'),channelName:attr('channel','name'),
versionCode:Number(code),version:attr('version','name')});
}
exports.parseVersionManifest=parseVersionManifest;
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "javascript",
"imported": true,
"uuid": "2de3f49a-c136-4137-9635-55a8598c867c",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -18,6 +18,10 @@ export class OwnedRoomHistory {
joined(raw:Readonly<Record<string,unknown>>):void{
if(Number(raw.state)===99||Number(raw.state)===0&&Number(raw.isowner)===1){const code=this.code(raw.roomcode);this.save(this.read().filter(n=>n!==code));}
}
broken(raw:Readonly<Record<string,unknown>>):void{
if(raw.roomcode===undefined)return;
const code=this.code(raw.roomcode);this.save(this.read().filter(n=>n!==code));
}
exited(raw:Readonly<Record<string,unknown>>,infinite:number):void{
if(Number(raw.isowner)!==1||infinite!==0)return;
const code=this.code(raw.roomcode),list=this.read();if(!list.includes(code))this.save([code,...list]);
@@ -0,0 +1,35 @@
import {logNativeCall} from '../../core/native-call-log.ts';
import type {NativeCallName} from './native-contracts.ts';
export type PlatformNativeHost=
|{kind:'browser'}
|{kind:'settings';settings:object}
|{kind:'bridge';call(name:NativeCallName,data:unknown):void};
// 05_Func.startlocation/getlocation: the browser source owns this test fixture.
// Native hosts always supply their own result through getlocationinfo.
const BROWSER_LOCATION=Object.freeze({
address:'江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)',city:'南昌市',cityCode:'0791',country:'中国',
district:'青云谱区',latitude:28.623546,longitude:115.900333,province:'江西省',street:'施尧路',errorCode:0,
});
function invoke(host:PlatformNativeHost,name:NativeCallName,data:unknown,args:unknown[]):void {
if(host.kind==='bridge'){host.call(name,data);return;}
if(host.kind==='browser'){logNativeCall('browser',name,args,false);return;}
const method=(host.settings as Record<string,unknown>)[name];
logNativeCall('settings',name,args,typeof method==='function');
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
Reflect.apply(method,host.settings,args);
}
export function startPlatformLocation(host:PlatformNativeHost,onLocation:(data:unknown)=>void):void {
invoke(host,'startlocation',2,[2]);
if(host.kind==='browser')onLocation(JSON.stringify(BROWSER_LOCATION));
}
export function callPlatformSetting(host:PlatformNativeHost,name:'stopshake'):void;
export function callPlatformSetting(host:PlatformNativeHost,name:'voicePlaying'|'SwitchShake',value:0|1):void;
export function callPlatformSetting(host:PlatformNativeHost,name:'stopshake'|'voicePlaying'|'SwitchShake',value?:0|1):void {
// Settings stopshake takes no args; WVJB explicitly takes an empty string.
invoke(host,name,name==='stopshake'?'':value,name==='stopshake'?[]:[value]);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "41e1b7ed-8004-4b1e-b41d-3013e5716ac2",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,134 @@
import {logNativeCall} from '../../core/native-call-log.ts';
import type {NativeBridgePort, NativeHandler, RegisteredNativeHandlerName} from './native-contracts.ts';
import type {PlatformNativeHost} from './platform-native.ts';
export interface RoomDeviceStatus {
readonly battery:number|null;
readonly network:1|2|3|null;
readonly wifiLevel:number|null;
readonly wifiFrame:1|2|3|4|null;
readonly batteryFrame:2|3|null;
}
export interface RoomDeviceStatusOptions {
host:PlatformNativeHost;
subscribe(name:RegisteredNativeHandlerName,handler:NativeHandler):()=>void;
}
// Browser fixture belongs to this source: 05_Func.getbattery/getnetwork return
// 1 in plain H5; MainScene initially maps every non-WiFi network to frame 4.
const BROWSER_STATUS:RoomDeviceStatus=Object.freeze({battery:1,network:1,wifiLevel:null,wifiFrame:4,batteryFrame:2});
/** Settings calls globals; WVJB registers the same names with different WiFi args. */
export function createRoomDeviceStatusSubscriptions(
host:PlatformNativeHost,globals:Record<string,unknown>,bridge?:Pick<NativeBridgePort,'register'>,
):RoomDeviceStatusOptions['subscribe'] {
if(host.kind==='bridge'&&!bridge)throw Error('Room device status bridge registration is required');
return (name,handler)=>{
if(name!=='getBattery'&&name!=='getwifiLevel'&&name!=='getnetwork')throw Error(`Unexpected room device status handler: ${name}`);
let active=true;
const guarded:NativeHandler=(...args)=>{if(active)return handler(...args);};
if(host.kind==='bridge'){
bridge!.register(name,guarded);
// WVJB exposes no unregister contract; native-retained callbacks become inert.
return ()=>{active=false;};
}
if(host.kind==='browser')return ()=>{active=false;};
const previous=Object.getOwnPropertyDescriptor(globals,name);
globals[name]=guarded;
return ()=>{
active=false;
if(globals[name]!==guarded)return;
if(previous)Object.defineProperty(globals,name,previous);
else delete globals[name];
};
};
}
function sourceNumber(value:unknown,name:string,min:number,max:number,integer=false):number {
if((typeof value!=='number'&&typeof value!=='string')||String(value).trim()==='')throw Error(`Invalid native ${name}`);
const number=Number(value);
if(!Number.isFinite(number)||number<min||number>max||(integer&&!Number.isInteger(number)))throw Error(`Invalid native ${name}: ${String(value)}`);
return number;
}
export class RoomDeviceStatusAdapter {
private state:RoomDeviceStatus={battery:null,network:null,wifiLevel:null,wifiFrame:null,batteryFrame:null};
private readonly listeners=new Set<(status:RoomDeviceStatus)=>void>();
private readonly unsubscribers:Array<()=>void>=[];
private disposed=false;
private readonly options:RoomDeviceStatusOptions;
constructor(options:RoomDeviceStatusOptions) {
this.options=options;
try {
this.unsubscribers.push(options.subscribe('getBattery',value=>{if(!this.disposed)this.receiveBattery(value);}));
this.unsubscribers.push(options.subscribe('getnetwork',value=>{if(!this.disposed)this.receiveNetwork(value,false);}));
this.unsubscribers.push(options.subscribe('getwifiLevel',(...args)=>{if(!this.disposed)this.receiveWifi(args);}));
this.refresh();
}catch(error){this.dispose();throw error;}
}
snapshot():RoomDeviceStatus {return this.state;}
/** Changes only; use snapshot() for the initial render. */
subscribe(listener:(status:RoomDeviceStatus)=>void):()=>void {
this.assertUsable();this.listeners.add(listener);return()=>{this.listeners.delete(listener);};
}
refresh():void {
this.assertUsable();
const host=this.options.host;
if(host.kind==='browser'){this.publish(BROWSER_STATUS);return;}
// Legacy WVJB Func getters return callback-populated app_* variables only.
// There are no getnetwork/getbattery/getwifisignalLevel callHandler methods.
if(host.kind==='bridge')return;
this.receiveNetwork(this.setting('getnetwork'),true);
this.receiveBattery(this.setting('getbattery'));
}
dispose():void {
if(this.disposed)return;
this.disposed=true;this.listeners.clear();
const errors:unknown[]=[];
for(const off of this.unsubscribers.splice(0)){try{off();}catch(error){errors.push(error);}}
if(errors.length)throw errors[0];
}
private receiveBattery(value:unknown):void {
const battery=sourceNumber(value,'battery',0,1);
this.publish({...this.state,battery,batteryFrame:battery<=0.2?3:2});
}
private receiveNetwork(value:unknown,entry:boolean):void {
const network=sourceNumber(value,'network',1,3,true) as 1|2|3;
// 05_Func.getnetwork callback type 1 preserves the displayed frame.
const wifiFrame=network===2?3:network===3||entry?4:this.state.wifiFrame;
this.publish({...this.state,network,wifiFrame});
if(network===2&&this.options.host.kind==='settings')this.setting('getwifisignalLevel');
}
private receiveWifi(args:unknown[]):void {
let raw:unknown;
if(this.options.host.kind==='settings')raw={ssidname:args[0],signalLevel:args[1]};
else raw=typeof args[0]==='string'?JSON.parse(args[0]):args[0];
if(!raw||typeof raw!=='object')throw Error('Invalid native wifi payload');
const wifiLevel=sourceNumber((raw as Record<string,unknown>).signalLevel,'wifi signalLevel',0,4,true);
this.publish({...this.state,wifiLevel,wifiFrame:wifiLevel===0?1:wifiLevel<=2?2:3});
}
private setting(name:'getnetwork'|'getbattery'|'getwifisignalLevel'):unknown {
const host=this.options.host;
if(host.kind!=='settings')throw Error('Device status settings host required');
const method=(host.settings as Record<string,unknown>)[name];
logNativeCall('settings',name,[],typeof method==='function');
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
return Reflect.apply(method,host.settings,[]);
}
private publish(state:RoomDeviceStatus):void {
this.state=Object.freeze(state);
for(const listener of this.listeners)listener(this.state);
}
private assertUsable():void {if(this.disposed)throw Error('Room device status adapter is disposed');}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e58a8fa9-83ef-4325-a40c-99e91f716092",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,121 @@
import {logNativeCall} from '../../core/native-call-log.ts';
import type {NativeBridgePort, NativeHandler, RegisteredNativeHandlerName} from './native-contracts.ts';
import type {PlatformNativeHost} from './platform-native.ts';
export interface RoomVoiceOptions {
host:PlatformNativeHost;
subscribe(name:RegisteredNativeHandlerName,handler:NativeHandler):()=>void;
/** Preserve the native duration's wire type; the runtime owns the envelope. */
sendVoice(voiceurl:string,time:string|number):void;
onPlayback(user:number,playing:boolean):void;
}
/** Settings calls top-level window functions; WVJB calls registered handlers. */
export function createRoomVoiceSubscriptions(
host:PlatformNativeHost,
globals:Record<string,unknown>,
bridge?:Pick<NativeBridgePort,'register'>,
):RoomVoiceOptions['subscribe'] {
if(host.kind==='bridge'&&!bridge)throw Error('Room voice bridge registration is required');
return (name,handler)=>{
if(name!=='getaudiourl'&&name!=='gameui_play_voice'&&name!=='gameui_stop_voice')throw Error(`Unexpected room voice handler: ${name}`);
let active=true;
const guarded:NativeHandler=(...args)=>{if(active)return handler(...args);};
if(host.kind==='bridge'){
bridge!.register(name,guarded);
// WVJB has no unregister contract. Retained native callbacks become inert.
return ()=>{active=false;};
}
if(host.kind==='browser')return ()=>{active=false;};
const previous=Object.getOwnPropertyDescriptor(globals,name);
globals[name]=guarded;
return ()=>{
active=false;
if(globals[name]!==guarded)return;
if(previous)Object.defineProperty(globals,name,previous);
else delete globals[name];
};
};
}
/** Native owns recorder UI and upload. Legacy defines no native stop/cancel command. */
export class RoomVoiceAdapter {
private pending=false;
private cancelled=false;
private disposed=false;
private readonly unsubscribers:Array<()=>void>=[];
private readonly options:RoomVoiceOptions;
constructor(options:RoomVoiceOptions) {
this.options=options;
try {
this.unsubscribers.push(options.subscribe('getaudiourl',(...args)=>this.uploaded(args)));
for(const [name,playing] of [['gameui_play_voice',true],['gameui_stop_voice',false]] as const){
this.unsubscribers.push(options.subscribe(name,(value)=>{
if(this.disposed)return;
if((typeof value!=='string'&&typeof value!=='number')||String(value).trim()===''||!Number.isInteger(Number(value)))throw Error('Invalid native voice user');
options.onPlayback(Number(value),playing);
}));
}
} catch(error) {this.dispose();throw error;}
}
startRecording():void {
this.assertUsable();
if(this.pending)throw Error('Native voice recording is pending');
this.pending=true;this.cancelled=false;
try {this.invoke('prepareaudio','',[]);}catch(error){this.pending=false;throw error;}
}
/** Pointer release does not end the native recorder; upload remains eligible. */
stopRecording():void {this.assertUsable();}
/** Local abandonment only. Keep the pending slot until native completes. */
cancelRecording():void {this.assertUsable();if(this.pending)this.cancelled=true;}
playVoice(audiourl:string,type:number,user:number):void {
this.assertUsable();
if(typeof audiourl!=='string'||audiourl.length===0)throw Error('Invalid native voice audiourl');
if(!Number.isInteger(type)||!Number.isInteger(user))throw Error('Invalid native voice type/user');
this.invoke('mediaTypeAudio',{audiourl,type,user},[audiourl,type,user]);
}
dispose():void {
if(this.disposed)return;
this.disposed=true;this.pending=false;
const errors:unknown[]=[];
for(const unsubscribe of this.unsubscribers.splice(0)){try{unsubscribe();}catch(error){errors.push(error);}}
if(errors.length)throw errors[0];
}
private uploaded(args:unknown[]):void {
if(this.disposed||!this.pending)return;
this.pending=false;
if(this.cancelled)return;
let raw:unknown;
if(this.options.host.kind==='settings')raw={audiourl:args[0],time:args[1]};
else raw=typeof args[0]==='string'?JSON.parse(args[0]):args[0];
if(!raw||typeof raw!=='object')throw Error('Invalid native voice upload');
const {audiourl,time}=raw as Record<string,unknown>;
if((typeof time!=='number'&&typeof time!=='string')||String(time).trim()===''||!Number.isFinite(Number(time)))throw Error('Invalid native voice time');
// Legacy suppresses zero and negative duration callbacks.
if(Number(time)<=0)return;
if(typeof audiourl!=='string'||audiourl.length===0)throw Error('Invalid native voice audiourl');
this.options.sendVoice(audiourl,time);
}
private invoke(name:'prepareaudio'|'mediaTypeAudio',data:unknown,args:unknown[]):void {
const host=this.options.host;
if(host.kind==='bridge'){host.call(name,data);return;}
if(host.kind==='browser'){
logNativeCall('browser',name,args,false);
throw Error('当前浏览器不支持原生语音录制或播放,请在客户端使用');
}
const method=(host.settings as Record<string,unknown>)[name];
logNativeCall('settings',name,args,typeof method==='function');
if(typeof method!=='function')throw Error(`Native settings missing ${name}`);
Reflect.apply(method,host.settings,args);
}
private assertUsable():void {if(this.disposed)throw Error('Room voice adapter is disposed');}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "17ae6470-6771-4c3a-9db6-409deca12dab",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -34,11 +34,13 @@ function bootstrapHostKind(options: BootstrapOptions): HostKind {
/** 兼容启动编排外观;所有运行契约均由唯一的 RuntimeConfig 边界产出。 */
export async function resolveBootstrap(options: BootstrapOptions): Promise<BootstrapResult> {
const runtime = await resolveRuntimeConfig({
buildIdentity: options.buildIdentity,
startupConfig: options.startupConfig,
hostKind: bootstrapHostKind(options),
win: options.win,
search: options.search,
fetcher: options.fetcher,
localFetcher: options.localFetcher,
cacheBust: options.cacheBust,
});
return Object.freeze({
@@ -0,0 +1,2 @@
/** Explicit client lifecycle budget; no protocol fields or game rules are inferred. */
export const ROOM_LIFECYCLE_CONFIG=Object.freeze({capacity:256,maxWaitMs:30000});
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "57882ffe-3e00-46d9-ab47-362e6dd4467d",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,4 +1,4 @@
import type { ChannelIdentity, IdentitySource } from './identity.ts';
import type { ChannelIdentity } from './identity.ts';
import { resolveIdentity } from './identity.ts';
import {
STARTUP_CONFIG,
@@ -15,7 +15,6 @@ import {
type RemoteConfig,
} from './remote-config.ts';
import type { RuntimeMode } from './runtime-mode.ts';
import { BUILD_IDENTITY } from './sources/defaults.ts';
import {
getothername,
nativeIdentitySource,
@@ -40,6 +39,7 @@ export interface RuntimeConfig {
}
export interface ResolveRuntimeConfigOptions {
readonly buildIdentity: Readonly<ChannelIdentity>;
readonly startupConfig?: Readonly<StartupConfig>;
readonly hostKind: HostKind;
readonly win: NativeHost;
@@ -50,16 +50,14 @@ export interface ResolveRuntimeConfigOptions {
readonly cacheBust?: () => string;
}
function buildIdentitySource(): IdentitySource {
return () => ({ gameid: BUILD_IDENTITY.gameid, version: BUILD_IDENTITY.version });
}
function resolveHostIdentity(options: ResolveRuntimeConfigOptions): Readonly<ChannelIdentity> {
if (!options.buildIdentity || typeof options.buildIdentity !== 'object') throw Error('Missing buildIdentity from application composition');
const buildIdentity = resolveIdentity([() => options.buildIdentity]);
if (options.hostKind === 'h5') {
return resolveIdentity([() => BUILD_IDENTITY, queryStringSource(options.search)]);
return resolveIdentity([() => buildIdentity, queryStringSource(options.search)]);
}
return resolveIdentity([
buildIdentitySource(),
() => ({gameid:buildIdentity.gameid,version:buildIdentity.version}),
nativeIdentitySource(options.win, options.hostKind),
]);
}
@@ -1,19 +1,2 @@
import type { ChannelIdentity } from '../identity.ts';
/** Legacy GameData launchWaitTime (04_Data.js): timer units are milliseconds. */
/** Legacy launchWaitTime, in milliseconds. */
export const DEFAULT_LAUNCH_WAIT_TIME_MS = 10;
/**
* 构建期默认渠道身份(旧 version.js 等价物)。
* gameid 每子游戏固定写死;agentid/channelid/marketid/version 为默认,运行期由 query/native/profile 覆盖。
*/
export const BUILD_IDENTITY: ChannelIdentity = {
// This game's entry in config_test/update_jsonv2.txt belongs to this agent/channel.
agentid: '1B2h0ccl205c390Y28m1Ajdplkuu4wgy',
channelid: 'aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG',
// agentid: 'veRa0qrBf0df2K1G4de2tgfmVxB2jxpv',
// channelid: 'FtJf073aa0d6rI1xD8J1Y42fINTm0ziK',
gameid: 'J9r4022t80pxlb01zOK76DN1WD4QewFl',
marketid: 4,
version: 1, // 数字 versionCode(联调时改成本地服要求的版本号)
};
@@ -362,7 +362,9 @@ export class WireClient {
if (!this.open || this.stopped || this.transport === null) {
throw new Error('connection is not open');
}
this.transport.send(serializeOutbound(envelope));
const frame = serializeOutbound(envelope);
this.logPacket('发送', frame);
this.transport.send(frame);
}
switchServer(url: string): void {
@@ -454,12 +456,19 @@ export class WireClient {
private onMessage(frame: string): void {
const decoded = decodeFrame(frame);
if (decoded.kind !== 'heartbeat') this.logPacket('接收', frame);
if (decoded.kind === 'ignore') return;
this.watchdog.feed();
if (decoded.kind !== 'message') return;
this.emit({ type: 'message', message: decoded.message });
}
private logPacket(direction: '发送' | '接收', frame: string): void {
if (STARTUP_CONFIG.debugIO) {
console.log(`[收发包] ${direction}`, {server:this.policy.current(),time:new Date().toISOString()}, frame);
}
}
private onReceiveTimeout(): void {
if (!this.open || this.stopped) return;
this.emit({ type: 'slow' });
@@ -1,4 +1,6 @@
import type { RuntimeConfig } from '../config/runtime-config.ts';
import type {GameRoomProjection} from '../sdk/contracts/game-room-projection.ts';
import {gameAllowsRoomAction} from '../sdk/room-action-policy.ts';
import { buildFirstSliceEnvelope } from '../protocol/first-slice-routes.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
import {
@@ -19,6 +21,8 @@ export interface OutboundEnvelopeSendPort {
}
export interface PlatformCommandsOptions {
/** Omitted/null explicitly means the game provides no additional restrictions. */
readonly getRoomProjection?:()=>GameRoomProjection|null;
readonly runtimeConfig: RuntimeConfig;
readonly store: PlatformStore;
readonly send: OutboundEnvelopeSendPort;
@@ -117,18 +121,73 @@ export class PlatformCommands {
prepare(): void {
const state = this.options.store.getState();
this.requireInsideRoom(state);
this.requireGameAction('prepare');
this.options.send.send(buildPrepareRequest(
this.options.runtimeConfig,
this.roomIdentity(state, this.requireInsideRoom(state)),
));
}
sendRoomText(text:string,announcement=false):void {
if(typeof text!=='string'||text.length===0)throw Error('room text must be nonempty');
this.sendSocial('send_text',{text,type:announcement?1:0});
}
sendRoomPhrase(text:string):void {
if(typeof text!=='string'||text.length===0)throw Error('room phrase must be nonempty');
this.sendSocial('send_text',{text});
}
sendRoomVoice(voiceurl:string,time:string|number):void {
if(typeof voiceurl!=='string'||voiceurl.length===0||!(typeof time==='number'||typeof time==='string'&&/^[0-9]+(?:\.[0-9]+)?$/.test(time))||!Number.isFinite(Number(time))||Number(time)<=0)throw Error('room voice requires uploaded URL and positive duration');
this.sendSocial('send_voice',{voiceurl,time});
}
sendRoomExpression(id:number):void {
if(!Number.isSafeInteger(id)||id<=0)throw Error('room expression requires positive ID');
this.sendSocial('send_phiz',{text:id});
}
breakRoom():void {
const room=this.requireInsideRoom(this.options.store.getState());
if(room.stage!==0||room.status!==1)throw Error('breakRoom requires pregame owner');
this.sendSocial('self_break_room',{});
}
surrenderRoom(count:number):void {
if(!Number.isFinite(count)||count<=0)throw Error('surrender count must be positive');
this.sendSocial('beanroom_surrender',{count});
}
changeRoom():void {this.sendSocial('change_room',{});}
private sendSocial(rpc:'send_text'|'send_voice'|'send_phiz'|'self_break_room'|'change_room'|'beanroom_surrender',data:Readonly<Record<string,unknown>>):void {
const state=this.options.store.getState(),room=this.requireInsideRoom(state);
if(state.app.phase!=='logged-in')throw Error('room command requires logged-in connection');
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope(rpc,{agentid,gameid,...this.roomIdentity(state,room),...data}));
}
applyDissolution():void {this.sendDissolution('self_apply_free_room');}
agreeDissolution():void {this.sendDissolution('self_agree_free_room');}
refuseDissolution():void {this.sendDissolution('self_refuse_free_room');}
private sendDissolution(rpc:'self_apply_free_room'|'self_agree_free_room'|'self_refuse_free_room'):void {
const state=this.options.store.getState(),room=this.requireInsideRoom(state);
if(state.app.phase!=='logged-in')throw Error('dissolution requires logged-in connection');
if(rpc==='self_apply_free_room'){
if(room.stage!==1)throw Error('dissolution application requires stage=1');
if(state.dissolution)throw Error('dissolution vote or result is already present');
this.requireGameAction('exit');
}else{
const vote=state.dissolution;
if(vote?.kind!=='active'||vote.participants.find(player=>player.seat===room.selfSeat)?.state!==-1)throw Error('dissolution response requires own waiting vote');
}
const {agentid,gameid}=this.options.runtimeConfig.identity;
this.options.send.send(buildFirstSliceEnvelope(rpc,{agentid,gameid,...this.roomIdentity(state,room)}));
}
exitRoom(): void {
const state = this.options.store.getState();
const room = this.requireInsideRoom(state);
if (room.stage !== 0 && room.infinite !== 1) {
throw new UnsupportedGameHostCommandError('room.exit requires stage=0 unless infinite=1');
}
this.requireGameAction('exit');
this.options.send.send(buildExitRoomRequest(
this.options.runtimeConfig,
this.roomIdentity(state, room),
@@ -161,6 +220,11 @@ export class PlatformCommands {
return state.room;
}
private requireGameAction(action:'prepare'|'exit'):void {
const projection=this.options.getRoomProjection===undefined?null:this.options.getRoomProjection();
if(!gameAllowsRoomAction(true,projection,action))throw new UnsupportedGameHostCommandError(`Game currently disallows room.${action}`);
}
private requireAuthenticatedPlayerId(state: PlatformState): number {
const playerid = state.players.selfPlayerId;
if (playerid === null) {
@@ -1,3 +1,7 @@
import type {NumericToolsPort,RoomCapabilities,InputResult,InputCancelReason,FeedbackOwnerPort,OwnerHandle} from '../sdk/contracts/game-capabilities.ts';
export type {NumericToolsPort} from '../sdk/contracts/game-capabilities.ts';
import {roomExitAction} from '../sdk/room-exit-action.ts';
import {requireRoomtype} from '../protocol/contracts/validation.ts';
import type {Roomtype} from '../sdk/contracts/roomtype.ts';
import { APP } from '../core/constants.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
@@ -25,7 +29,10 @@ export class GameSessionDisposedError extends Error {
}
}
export interface CreateGameHostAdapterOptions {
readonly numericTools?:NumericToolsPort;
readonly feedback?:FeedbackOwnerPort;
readonly gameEntry: GameEntry;
readonly store: PlatformStore;
readonly commands: PlatformCommands;
@@ -75,8 +82,16 @@ class SessionBoundGameHost {
private readonly subscriptions = new Set<SubscriptionRecord>();
private invalidationErrors: unknown[] | null = null;
private disposed = false;
private numericTools:NumericToolsPort|null;
private feedback:FeedbackOwnerPort|null;
private numericToolsUsed=false;
private inputGeneration=0;
private multiplierGeneration=0;
private readonly cleanups=new Set<()=>void>();
private readonly pendingInputs=new Map<'input'|'multiplier',{cancel(reason:InputCancelReason):void}>();
constructor(options: CreateGameHostAdapterOptions) {
this.numericTools=options.numericTools??null;this.feedback=options.feedback??null;
const route = options.gameEntry.route;
assertGameRoute(route);
const state = options.store.getState();
@@ -111,7 +126,20 @@ class SessionBoundGameHost {
},
});
const capabilities:RoomCapabilities=Object.freeze({
room:Object.freeze({seat,getSnapshot:()=>this.getSnapshot(),subscribe:(listener:(snapshot:PlatformGameSnapshot)=>void)=>this.subscribe(listener),
prepare:()=>this.execute({type:'room.prepare'}),requestExit:()=>this.execute({type:roomExitAction(this.getSnapshot().room).command}),
applyDissolution:()=>this.execute({type:'room.apply-dissolution'})}),
messages:Object.freeze({send:(rpc:string,data:import('../sdk/contracts/roomtype.ts').JsonValue)=>{this.requireCurrentRoom();requireRoomtype(data,'$.game.data');this.sendGameMessage(rpc,data);}}),
ui:Object.freeze({beginLoading:()=>this.ownFeedback(port=>port.beginLoading()),showBusinessTip:(message:string,time:number)=>this.ownFeedback(port=>port.showBusinessTip(message,time)),requestNumber:(request:{message:string})=>this.requestNumber<number>(request.message,false),
requestDigitText:(request:{message:string})=>this.requestNumber<string>(request.message,true),
requestMultiplier:(request:{values:readonly number[]})=>this.requestMultiplier(request.values),
cancelNumber:()=>this.execute({type:'ui.close-number-input'}),cancelMultiplier:()=>this.execute({type:'ui.hide-multiplier'})}),
scope:Object.freeze({get active(){return owner.isActiveRoom();},defer:(cleanup:()=>void)=>this.defer(cleanup)}),
});
const owner=this;
this.host = Object.freeze({
capabilities,
seat,
getSnapshot: (): PlatformGameSnapshot => this.getSnapshot(),
subscribe: (listener: (snapshot: PlatformGameSnapshot) => void): (() => void) => (
@@ -132,9 +160,19 @@ class SessionBoundGameHost {
this.commands = null;
this.sendPort = null;
this.route = null;
this.feedback=null;
this.roomGeneration = null;
const errors: unknown[] = [];
for(const pending of this.pendingInputs.values())pending.cancel('scope-ended');
this.pendingInputs.clear();
for(const cleanup of [...this.cleanups].reverse()){this.cleanups.delete(cleanup);try{cleanup();}catch(error){errors.push(error);}}
const numericTools=this.numericTools;
this.numericTools=null;
this.inputGeneration++;this.multiplierGeneration++;
if(this.numericToolsUsed&&numericTools!==null){
try{numericTools.close();}catch(error){errors.push(error);}
}
this.invalidationErrors = errors;
try {
for (const record of subscriptions) {
@@ -206,10 +244,38 @@ class SessionBoundGameHost {
const commands = this.commands;
if (commands === null) throw new GameSessionDisposedError();
if (command !== null && typeof command === 'object') {
if(command.type==='ui.open-number-input'||command.type==='ui.close-number-input'
||command.type==='ui.show-multiplier'||command.type==='ui.hide-multiplier'){
const tools=this.numericTools;
if(tools===null)throw new UnsupportedGameHostCommandError('Numeric tools presentation is unavailable');
this.numericToolsUsed=true;
if(command.type==='ui.close-number-input'){this.cancelInput('input','user');this.inputGeneration++;tools.closeNumberInput();return;}
if(command.type==='ui.hide-multiplier'){this.cancelInput('multiplier','user');this.multiplierGeneration++;tools.hideMultiplier();return;}
if(typeof command.onConfirm!=='function')throw TypeError('Numeric tools require a confirmation callback');
const current=():boolean=>!this.disposed&&this.store!==null&&this.isCurrentRoom(this.store.getState());
if(command.type==='ui.open-number-input'){
this.cancelInput('input','replaced');
const generation=++this.inputGeneration;
tools.openNumberInput({message:command.message,begin0:command.begin0,onConfirm:value=>{
if(current()&&generation===this.inputGeneration)command.onConfirm(value);
}});
}else{
this.cancelInput('multiplier','replaced');
const generation=++this.multiplierGeneration;
tools.showMultiplier({values:[...command.values],onConfirm:values=>{
if(current()&&generation===this.multiplierGeneration)command.onConfirm(values);
}});
}
return;
}
if (command.type === 'room.prepare') {
commands.prepare();
return;
}
if(command.type==='room.apply-dissolution'){
commands.applyDissolution();
return;
}
if (command.type === 'room.exit') {
commands.exitRoom();
return;
@@ -218,6 +284,72 @@ class SessionBoundGameHost {
throw new UnsupportedGameHostCommandError('unsupported GameHost command');
}
private ownFeedback(acquire:(port:FeedbackOwnerPort)=>OwnerHandle):OwnerHandle {
this.requireCurrentRoom();const port=this.feedback;if(!port)throw new UnsupportedGameHostCommandError('Owned feedback presentation is unavailable');
const handle=acquire(port);if(!handle||typeof handle.close!=='function')throw TypeError('Feedback must return an owner handle');
if(!this.isActiveRoom()){handle.close();throw new GameSessionDisposedError();}
let closed=false;let unregister=()=>{};
const owned=Object.freeze({close:()=>{if(closed)return;closed=true;unregister();handle.close();}});
unregister=this.defer(()=>owned.close());return owned;
}
private isActiveRoom():boolean {return !this.disposed&&this.store!==null&&this.isCurrentRoom(this.store.getState());}
private defer(cleanup:()=>void):()=>void {
this.requireCurrentRoom();if(typeof cleanup!=='function')throw TypeError('Scope cleanup must be a function');
const registered=()=>cleanup();this.cleanups.add(registered);return ()=>{this.cleanups.delete(registered);};
}
private cancelInput(kind:'input'|'multiplier',reason:InputCancelReason):void {this.pendingInputs.get(kind)?.cancel(reason);}
private requestNumber<T extends number|string>(message:string,text:boolean):Promise<InputResult<T>> {
return this.requestInput<T>('input',(tools,confirm,cancel,fail)=>{
if(typeof message!=='string')throw TypeError('Number input message must be a string');
tools.openNumberInput({message,begin0:text,onCancel:cancel,onConfirm:value=>{
if(typeof value!==(text?'string':'number')||(typeof value==='number'&&!Number.isFinite(value))){fail(TypeError('Number input returned the wrong result type'));return;}
confirm(value as T);
}});
});
}
private requestMultiplier(values:readonly number[]):Promise<InputResult<readonly number[]>> {
return this.requestInput<readonly number[]>('multiplier',(tools,confirm,cancel,fail)=>{
if(!Array.isArray(values)||!values.every(Number.isFinite))throw TypeError('Multiplier requires finite values');
tools.showMultiplier({values:Object.freeze([...values]),onCancel:cancel,onConfirm:result=>{
if(!Array.isArray(result)||!result.every(Number.isFinite)){fail(TypeError('Multiplier returned non-finite values'));return;}
confirm(Object.freeze([...result]));
}});
});
}
private requestInput<T>(kind:'input'|'multiplier',open:(tools:NumericToolsPort,confirm:(value:T)=>void,cancel:()=>void,fail:(error:unknown)=>void)=>void):Promise<InputResult<T>> {
return new Promise((resolve,reject)=>{
try{
this.requireCurrentRoom();
if(this.pendingInputs.has(kind))throw Error('An input request of this kind is already pending');
const tools=this.numericTools;if(tools===null)throw new UnsupportedGameHostCommandError('Numeric tools presentation is unavailable');
this.numericToolsUsed=true;
if(kind==='input')this.inputGeneration++;else this.multiplierGeneration++;
let settled=false;
const finish=(result:InputResult<T>):void=>{if(settled)return;settled=true;this.pendingInputs.delete(kind);resolve(Object.freeze(result));};
this.pendingInputs.set(kind,{cancel:reason=>finish({kind:'cancelled',reason})});
const fail=(error:unknown):void=>{
if(settled)return;settled=true;this.pendingInputs.delete(kind);
try{if(kind==='input')tools.closeNumberInput();else tools.hideMultiplier();}
catch(cleanup){reject(Object.assign(new Error('Input request and cleanup failed'),{errors:Object.freeze([error,cleanup])}));return;}
reject(error);
};
try{open(tools,value=>{
if(settled)return;
if(!this.isActiveRoom()){finish({kind:'cancelled',reason:'scope-ended'});return;}
settled=true;this.pendingInputs.delete(kind);
try{if(kind==='input')tools.closeNumberInput();else tools.hideMultiplier();resolve(Object.freeze({kind:'confirmed',value}));}catch(error){reject(error);}
},()=>finish({kind:'cancelled',reason:'user'}),fail);}
catch(error){fail(error);}
}catch(error){reject(error);}
});
}
private requireStore(): PlatformStore {
this.requireActive();
const store = this.store;
@@ -0,0 +1,9 @@
{
"ver": "1.2.0",
"importer": "directory",
"imported": true,
"uuid": "366456c6-a70b-47c0-8ad8-ffe01393a51e",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,99 @@
import {RoomScope,type RoomDisposer} from './room-scope.ts';
import {RoomMailbox,assertRoomMailboxOptions,type RoomMailboxOptions} from './room-mailbox.ts';
export type RoomCoordinatorState='outside'|'entering'|'active'|'failed';
export type RoomMountResult=void|RoomDisposer;
export interface RoomEntryOptions<T> {
readonly invalidate:RoomDisposer;
readonly mount:(scope:RoomScope)=>RoomMountResult|PromiseLike<RoomMountResult>;
readonly ready:()=>void;
readonly handle:(message:T)=>void;
}
export class RoomCoordinatorFailure extends Error {
readonly errors:readonly unknown[];
constructor(readonly generation:number,errors:readonly unknown[]){
super(`Room generation ${generation} failed`);this.name='RoomCoordinatorFailure';this.errors=Object.freeze([...errors]);
}
}
export interface RoomCoordinatorOptions extends RoomMailboxOptions {
readonly onFailure:(failure:RoomCoordinatorFailure)=>void;
}
interface Session<T>{scope:RoomScope;mailbox:RoomMailbox<T>;entry:RoomEntryOptions<T>;draining:boolean;}
/** Serializes room input across async scene mounting and revokes stale generations immediately. */
export class RoomCoordinator<T> {
private current:Session<T>|null=null;
private nextGeneration=0;
private status:RoomCoordinatorState='outside';
private lastFailure:RoomCoordinatorFailure|null=null;
private lastDiagnostic:RoomCoordinatorFailure|null=null;
constructor(private readonly options:RoomCoordinatorOptions){assertRoomMailboxOptions(options);if(typeof options.onFailure!=='function')throw Error('Room coordinator requires a diagnostic failure handler');}
get state():RoomCoordinatorState{return this.status;}
get scope():RoomScope|null{return this.current?.scope??null;}
get generation():number{return this.nextGeneration;}
get failure():RoomCoordinatorFailure|null{return this.lastFailure;}
get diagnosticFailure():RoomCoordinatorFailure|null{return this.lastDiagnostic;}
enter(entry:RoomEntryOptions<T>):RoomScope{
const cleanup=this.cancel();
const scope=new RoomScope(++this.nextGeneration,entry.invalidate);
const session:Session<T>={scope,entry,draining:false,mailbox:new RoomMailbox<T>(this.options,error=>this.fail(session,error))};
scope.own(()=>session.mailbox.dispose());
this.current=session;this.status='entering';this.lastFailure=null;
if(cleanup.length){this.fail(session,...cleanup);return scope;}
try{
const result=entry.mount(scope);
if(result!==undefined&&typeof result!=='function'&&typeof result.then==='function'){
void Promise.resolve(result).then(resource=>this.mounted(session,resource),error=>this.mountRejected(session,error));
}else this.mounted(session,result as RoomMountResult);
}catch(error){if(this.isCurrent(session))this.fail(session,error);else throw error;}
return scope;
}
dispatch(message:T):void{
const session=this.current;
if(!session||!session.scope.active)throw Error(`Cannot dispatch room input while ${this.status}`);
try{
session.mailbox.enqueue(message);
if(this.status==='active')this.drain(session);
}catch(error){if(this.isCurrent(session))this.fail(session,error);else throw error;}
}
/** Callers aggregate explicit cancellation cleanup errors with their existing teardown errors. */
cancel():readonly unknown[]{
const session=this.current;this.current=null;this.status='outside';
return session?.scope.dispose()??[];
}
private isCurrent(session:Session<T>):boolean{return this.current===session&&session.scope.active;}
private mounted(session:Session<T>,resource:RoomMountResult):void{
try{
if(resource!==undefined)session.scope.own(resource);
if(!this.isCurrent(session))return;
session.entry.ready();
if(!this.isCurrent(session))return;
this.status='active';this.drain(session);
}catch(error){if(this.isCurrent(session))this.fail(session,error);else this.report(new RoomCoordinatorFailure(session.scope.generation,[error]));}
}
private mountRejected(session:Session<T>,error:unknown):void{
if(this.isCurrent(session))this.fail(session,error);
// Rejected stale loads own no mount resource; their superseded scope was already released.
}
private drain(session:Session<T>):void{
if(session.draining)return;
session.draining=true;
try{session.mailbox.drain(session.entry.handle,()=>this.isCurrent(session));}
finally{session.draining=false;}
}
private fail(session:Session<T>,...errors:unknown[]):void{
if(!this.isCurrent(session))return;
this.current=null;this.status='failed';
const failure=new RoomCoordinatorFailure(session.scope.generation,[...errors,...session.scope.dispose()]);
this.lastFailure=failure;this.report(failure);
}
private report(failure:RoomCoordinatorFailure):void{
this.lastDiagnostic=failure;
try{this.options.onFailure(failure);}catch(error){
// Preserve diagnostic callback faults without creating an unhandled async rejection.
const diagnostic=new RoomCoordinatorFailure(failure.generation,[...failure.errors,error]);
this.lastDiagnostic=diagnostic;
if(this.nextGeneration===failure.generation)this.lastFailure=diagnostic;
}
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "e56782fe-72cd-4748-a2b4-8907281be052",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,43 @@
export interface RoomClock {
setTimeout(callback:()=>void,milliseconds:number):unknown;
clearTimeout(handle:unknown):void;
}
export interface RoomMailboxOptions {
readonly capacity:number;
readonly maxWaitMs:number;
readonly clock:RoomClock;
}
export function assertRoomMailboxOptions(options:RoomMailboxOptions):void{
if(!Number.isSafeInteger(options.capacity)||options.capacity<1)throw Error('Room mailbox capacity must be a positive safe integer');
if(!Number.isFinite(options.maxWaitMs)||options.maxWaitMs<=0)throw Error('Room mailbox maxWaitMs must be positive and finite');
if(typeof options.clock?.setTimeout!=='function'||typeof options.clock?.clearTimeout!=='function')throw Error('Room mailbox requires a clock');
}
/** FIFO queue owned by a room scope; its first pending message starts the wait deadline. */
export class RoomMailbox<T> {
private readonly messages:T[]=[];
private timer:{handle:unknown}|null=null;
private disposed=false;
constructor(private readonly options:RoomMailboxOptions,private readonly onTimeout:(error:Error)=>void){assertRoomMailboxOptions(options);}
get size():number{return this.messages.length;}
enqueue(message:T):void{
if(this.disposed)throw Error('Room mailbox is disposed');
if(this.messages.length>=this.options.capacity)throw Error(`Room mailbox capacity exceeded (${this.options.capacity})`);
this.messages.push(message);
if(this.timer===null){this.timer={handle:this.options.clock.setTimeout(()=>{
this.timer=null;
if(!this.disposed&&this.messages.length>0)this.onTimeout(Error(`Room mailbox timed out after ${this.options.maxWaitMs} ms`));
},this.options.maxWaitMs)};}
}
drain(handle:(message:T)=>void,active:()=>boolean):void{
while(!this.disposed&&active()&&this.messages.length>0){
const message=this.messages.shift() as T;
if(this.messages.length===0)this.clearTimer();
handle(message);
}
}
dispose():void{this.disposed=true;this.messages.length=0;this.clearTimer();}
private clearTimer():void{
const timer=this.timer;this.timer=null;
if(timer!==null)this.options.clock.clearTimeout(timer.handle);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "ca845f16-3594-4150-b744-315e56d38814",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,29 @@
export type RoomDisposer=()=>void;
/** Owns one room generation; the injected invalidator revokes the existing host lease. */
export class RoomScope {
private live=true;
private readonly disposers=new Set<RoomDisposer>();
constructor(readonly generation:number,private readonly invalidate:RoomDisposer){
if(!Number.isSafeInteger(generation)||generation<1)throw Error('Invalid room scope generation');
if(typeof invalidate!=='function')throw Error('Room scope requires a lease invalidator');
}
get active():boolean{return this.live;}
get resourceCount():number{return this.disposers.size;}
/** The returned release is idempotent and unregisters before invoking user cleanup. */
own(dispose:RoomDisposer):RoomDisposer{
if(typeof dispose!=='function')throw Error('Room scope requires a disposer');
let owned=true;
const release=()=>{if(!owned)return;owned=false;this.disposers.delete(release);dispose();};
if(this.live)this.disposers.add(release);else release();
return release;
}
dispose():readonly unknown[]{
if(!this.live)return [];
this.live=false;
const errors:unknown[]=[];
try{this.invalidate();}catch(error){errors.push(error);}
for(const release of [...this.disposers].reverse())try{release();}catch(error){errors.push(error);}
return errors;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "11761871-986b-4241-b53b-1f277f35ce1d",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,63 @@
import type {GameEntry,GameHost,PlatformToGameEvent,GameServerMessage} from '../../sdk/contracts/index.ts';
import {GameSessionHost} from '../../sdk/runtime/game-session-host.ts';
import type {ScenePort} from '../scene-port.ts';
import {RoomCoordinator,type RoomCoordinatorOptions} from './room-coordinator.ts';
/** Module attachment precedes view binding; all room input waits for the bound view. */
export class SceneOrderedGameSessionHost extends GameSessionHost {
private readonly coordinator:RoomCoordinator<()=>void>;
private synchronousErrors:unknown[]|null=null;
private packetDepth=0;
constructor(entry:GameEntry,private readonly scene:ScenePort,options:RoomCoordinatorOptions,
private readonly invalidateHost:(host:GameHost)=>void){
super(entry);
this.coordinator=new RoomCoordinator({...options,onFailure:failure=>{
if(this.synchronousErrors!==null)this.synchronousErrors.push(...failure.errors);
else options.onFailure(failure);
}});
}
open(host:GameHost):void{
super.open(host);
if(this.state!=='active')return;
this.synchronous(()=>this.coordinator.enter({invalidate:()=>this.invalidateHost(host),
mount:scope=>this.scene.showRoom(scope),ready:()=>{},handle:action=>action()}));
}
publish(event:PlatformToGameEvent):void{this.dispatch(()=>super.publish(event));}
dispatchGameMessage(route:string,message:GameServerMessage):void{this.dispatch(()=>super.dispatchGameMessage(route,message));}
restore(deskinfo:unknown):void{this.dispatch(()=>super.restore(deskinfo));}
get isRoomScopeAvailable():boolean{return this.coordinator.scope?.active===true;}
/** Preserve a complete packet's Store commit and game callbacks as one FIFO transaction. */
queueRoomPacket(action:()=>void):void{
this.synchronous(()=>this.coordinator.dispatch(()=>{
this.packetDepth++;
try{action();}finally{this.packetDepth--;}
}));
}
showRoomAfterOpen():void{
if(this.coordinator.state!=='entering'&&this.coordinator.state!=='active'&&this.state!=='disposed')throw Error('Cannot show room without a live room scope');
}
/** Connection loss cancels slow mount immediately; an already mounted settlement remains owned. */
cancelPendingMount():void{if(this.coordinator.state==='entering')this.close();}
close():void{this.release(false);}
dispose():void{this.release(true);}
private release(terminal:boolean):void{
const errors=[...this.coordinator.cancel()];
try{if(terminal)super.dispose();else super.close();}catch(error){errors.push(error);}
this.throwErrors(errors);
}
private dispatch(action:()=>void):void{
if(this.state==='disposed')throw Error('Cannot dispatch to a disposed game session');
if(this.packetDepth>0){action();return;}
this.synchronous(()=>this.coordinator.dispatch(action));
}
/** Wire callbacks remain synchronous faults; only deferred work uses onFailure. */
private synchronous(action:()=>unknown):void{
const previous=this.synchronousErrors,errors:unknown[]=[];this.synchronousErrors=errors;
try{action();}catch(error){errors.push(error);}finally{this.synchronousErrors=previous;}
this.throwErrors(errors);
}
private throwErrors(errors:readonly unknown[]):void{
if(errors.length===1)throw errors[0];
if(errors.length>1)throw Object.assign(Error('Room scope operation failed'),{errors});
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "19c227d9-98e6-42f2-9c1e-9c6021eb44b7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,3 +1,5 @@
import type {RoomEntryInput,RoomStartMessage} from '../sdk/contracts/platform-events.ts';
import {requireRecord} from '../protocol/contracts/validation.ts';
import type { GameHost } from '../sdk/contracts/index.ts';
import {
extractGameSessionOpenFaultErrors,
@@ -12,6 +14,7 @@ import {
} from '../protocol/contracts/index.ts';
import { cloneAndFreezeRoomtype, PlatformStore } from './stores/platform-store.ts';
import type { PlatformState } from './stores/platform-types.ts';
import type {DissolutionResult} from './stores/platform-types.ts';
import type {LoginAccountIdentity} from '../protocol/contracts/login-contract.ts';
export interface GameHostLeasePort {
@@ -42,7 +45,7 @@ export class RuntimeSessionFault extends Error {
readonly errors: readonly unknown[];
constructor(operation: string, errors: readonly unknown[]) {
super(`RuntimeSession fault during ${operation}`);
super(`RuntimeSession fault during ${operation}: ${errors.map(error => String(error)).join('; ')}`);
this.name = 'RuntimeSessionFault';
this.errors = Object.freeze([...errors]);
}
@@ -92,7 +95,7 @@ export class RuntimeSession {
this.releaseOrFault('login lobby replacement');
return;
}
this.openCommittedRoom(committed, parsed.reconnect, 'login room open');
this.openCommittedRoom(committed, parsed.reconnect, 'login room open',parsed.reconnect.present?{kind:'login-restored',sourceRpc:'player_login',raw:parsed.raw,deskinfo:parsed.reconnect.value}:{kind:'login-waiting',sourceRpc:'player_login',raw:parsed.raw});
}
handleCreateRoom(data: unknown): void {
@@ -110,10 +113,10 @@ export class RuntimeSession {
const roomtype = cloneAndFreezeRoomtype(parsed.room.roomtype);
const count = this.options.gameSession.entry.resolveSeatCount(roomtype);
const committed = this.options.store.createRoom(parsed, count);
this.openCommittedRoom(committed, {present: false}, 'create room open');
this.openCommittedRoom(committed, {present: false}, 'create room open',{kind:'created',sourceRpc:'create_room',raw:parsed.raw});
}
handleSelfJoin(data: unknown): void {
handleSelfJoin(data: unknown, message?:RoomStartMessage): void {
this.requireOperational();
const parsed = parseSelfJoinRoomResponse(data);
this.options.roomHistory?.joined(parsed.raw);
@@ -124,10 +127,21 @@ export class RuntimeSession {
if (parsed.room !== null && this.lastJoinedRoomcode === parsed.room.roomcode
&& this.options.gameSession.state === 'active') return;
const committed = this.options.store.replaceRoom(parsed);
this.openCommittedRoom(committed, parsed.reconnect, 'self join room open');
const started=parsed.raw.deskwar===1;
const reconnect=started?{present:false} as const:parsed.reconnect;
this.openCommittedRoom(committed,reconnect,'self join room open',started?{kind:'joined-started',sourceRpc:'self_join_room',raw:parsed.raw}:reconnect.present?{kind:'joined-snapshot',sourceRpc:'self_join_room',raw:parsed.raw,deskinfo:reconnect.value}:{kind:'joined-waiting',sourceRpc:'self_join_room',raw:parsed.raw});
if(started)this.options.gameSession.publish({type:'room.started',sourceRpc:'self_join_room',message:message??{route:'agent',rpc:'self_join_room',data:parsed.raw}});
this.lastJoinedRoomcode = parsed.room!.roomcode;
}
handleBreakRoom(payload:unknown):void {
this.requireOperational();
if(this.options.store.getState().room.kind==='outside')return;
this.options.roomHistory?.broken(requireRecord(payload,'$.data'));
this.options.store.clearRoom();
this.releaseOrFault('break room cleanup');
}
handleSelfExit(parsed: ParsedSelfExitPayload): void {
this.requireOperational();
const state = this.options.store.getState();
@@ -172,6 +186,29 @@ export class RuntimeSession {
if (errors.length > 0) this.throwFault('server switch', errors);
}
/** The server returns free_room only after this switch; settlement still owns the game lease. */
handleDissolutionServerSwitch(switchConnection:()=>void):void {
this.requireOperational();
if(this.options.store.getState().room.kind==='outside'&&this.options.gameSession.state==='idle'&&this.activeLease===null){
switchConnection();
return;
}
if(this.options.gameSession.state!=='active')throw Error('Dissolution server switch requires an active game session');
switchConnection();
}
handleDissolutionResult(result:DissolutionResult):void {
this.requireOperational();
if(!result.passed)throw Error('Dissolution settlement requires a server-approved result');
if(result.deskfree!==undefined){
this.options.gameSession.publish({type:'room.dissolution-confirmed',freeNow:result.freeNow,deskfree:result.deskfree,
...(result.tips!==undefined?{tips:result.tips}:{}),...(result.time!==undefined?{time:result.time}:{})});
return;
}
this.options.store.clearRoom();
this.releaseOrFault('dissolution without settlement cleanup');
}
handleKick(_data: unknown, stopConnection: () => void): void {
if (this.wasKicked) return;
this.requireOperational();
@@ -191,6 +228,7 @@ export class RuntimeSession {
state: PlatformState,
reconnect: ReconnectSnapshot,
operation: string,
entry: RoomEntryInput,
): void {
if (state.room.kind !== 'inside') {
this.throwFault(operation, [new Error('Cannot open a game session without committed room state')]);
@@ -217,6 +255,7 @@ export class RuntimeSession {
this.options.gameSession.publish({
type: 'room.entered',
roomtype: state.room.roomtype,
entry,
});
if (this.gameWasDisposed()) return;
if (reconnect.present) this.options.gameSession.restore(reconnect.value);
@@ -1,7 +1,7 @@
import type { RuntimeConfig } from '../config/runtime-config.ts';
import { LOGIN_GUARD_MS } from '../core/constants.ts';
import { STARTUP_CONFIG } from '../config/profiles.ts';
import type { OutboundEnvelope } from '../core/types/envelope.ts';
import type { InboundMessage, OutboundEnvelope } from '../core/types/envelope.ts';
import { realClock, type Clock } from '../net/heartbeat.ts';
import type { WireClient, WireEvent } from '../net/wire-client.ts';
import {
@@ -11,14 +11,16 @@ import {
} from '../protocol/contracts/index.ts';
import { PlatformHandlers } from '../protocol/platform-handlers.ts';
import { Router } from '../protocol/router.ts';
import { isLoginResponse } from '../protocol/first-slice-routes.ts';
import { FIRST_SLICE_RPC_ROUTE, isLoginResponse } from '../protocol/first-slice-routes.ts';
import type { GameEntry, GameHost } from '../sdk/contracts/index.ts';
import { GameSessionHost } from '../sdk/runtime/game-session-host.ts';
import {SceneOrderedGameSessionHost} from './room/scene-game-session.ts';
import {ROOM_LIFECYCLE_CONFIG} from '../config/room-lifecycle.ts';
import { PlatformCommands, type JoinRoomCommand, type CreateRoomCommand } from './commands.ts';
import type { ConnectionIntent } from './connection-intent.ts';
import {
createGameHostAdapter,
type GameHostLease,
type NumericToolsPort,
} from './game-host-adapter.ts';
import { RuntimeSession } from './runtime-session.ts';
import type { ScenePort } from './scene-port.ts';
@@ -32,6 +34,8 @@ export type PlatformWireClient = Pick<
>;
export interface PlatformRuntimeOptions {
readonly feedback?:import('../sdk/contracts/game-capabilities.ts').FeedbackOwnerPort;
readonly numericTools?:NumericToolsPort;
readonly gameEntry: GameEntry;
readonly resolveRuntimeConfig: () => Promise<RuntimeConfig>;
readonly createWireClient: (config: RuntimeConfig) => PlatformWireClient;
@@ -45,51 +49,6 @@ export interface PlatformRuntimeOptions {
type RuntimeLifecycle = 'idle' | 'starting' | 'running' | 'kicked' | 'fatal' | 'stopped';
/** Keeps the scene transition between room activation and optional snapshot restore. */
class SceneOrderedGameSessionHost extends GameSessionHost {
private roomSceneShown = false;
constructor(entry: GameEntry, private readonly scene: ScenePort) {
super(entry);
}
open(host: GameHost): void {
this.roomSceneShown = false;
super.open(host);
}
restore(deskinfo: unknown): void {
this.showRoomAfterOpen();
if (this.state === 'disposed') return;
super.restore(deskinfo);
}
showRoomAfterOpen(): void {
if (this.roomSceneShown) return;
if (this.state !== 'active') {
throw new Error('Cannot show the room without an active game session');
}
this.roomSceneShown = true;
this.scene.showRoom();
}
close(): void {
try {
super.close();
} finally {
this.roomSceneShown = false;
}
}
dispose(): void {
try {
super.dispose();
} finally {
this.roomSceneShown = false;
}
}
}
class PlatformRuntimeCleanupError extends Error {
readonly errors: readonly unknown[];
@@ -222,6 +181,10 @@ export class PlatformRuntime {
const gameSession = new SceneOrderedGameSessionHost(
this.options.gameEntry,
this.options.scene,
{...ROOM_LIFECYCLE_CONFIG,clock:this.clock,onFailure:failure=>{
if(!this.isTerminal())this.showFatalOnce(failure);
}},
host=>{if(this.activeGameHostLease?.host===host)this.activeGameHostLease.invalidate();},
);
const send = Object.freeze({
send: (envelope: OutboundEnvelope): void => {
@@ -232,6 +195,7 @@ export class PlatformRuntime {
runtimeConfig: config,
store: this.store,
send,
getRoomProjection:()=>gameSession.roomProjection?.getSnapshot()??null,
});
const session = new RuntimeSession({
roomHistory:this.options.roomHistory,
@@ -318,6 +282,26 @@ export class PlatformRuntime {
this.requireCommands().prepare();
}
sendRoomText(text:string,announcement=false):void {this.requireCommands().sendRoomText(text,announcement);}
sendRoomPhrase(text:string):void {this.requireCommands().sendRoomPhrase(text);}
sendRoomVoice(voiceurl:string,time:string|number):void {this.requireCommands().sendRoomVoice(voiceurl,time);}
sendRoomExpression(id:number):void {this.requireCommands().sendRoomExpression(id);}
breakRoom():void {this.requireCommands().breakRoom();}
surrenderRoom(count:number):void {this.requireCommands().surrenderRoom(count);}
changeRoom():void {this.requireCommands().changeRoom();}
applyDissolution():void {this.requireCommands().applyDissolution();}
agreeDissolution():void {this.requireCommands().agreeDissolution();}
refuseDissolution():void {this.requireCommands().refuseDissolution();}
acknowledgeDissolution():void {
const result=this.state.dissolution;
if(result?.kind!=='result')throw Error('dissolution acknowledgement requires a result');
if(result.passed&&!result.freeNow){
this.requireSession().handleDissolutionResult(result);
if(this.state.room.kind==='outside'){this.options.scene.showLobby();return;}
}
if(this.state.dissolution===result)this.store.acknowledgeDissolution();
}
getVipRooms(codes:readonly (string|number)[]):void {this.requireCommands().getVipRooms(codes);}
getVipRankList(limit:number):void {this.requireCommands().getVipRankList(limit);}
joinOwnedRoom(code:number,location:unknown):void {this.requireCommands().joinOwnedRoom(code,location);}
@@ -425,16 +409,8 @@ export class PlatformRuntime {
if (isLoginResponse(event.message)) {
this.clearLoginGuard();
}
const stateBeforeDispatch = this.store.getState();
const dispatchResult = this.requireRouter().dispatch(event.message);
this.throwObserverDispatchError();
if (
dispatchResult === 'handled-platform'
&& this.lifecycle === 'running'
&& this.store.getState() !== stateBeforeDispatch
) {
this.updateSceneAfterMessage(event.message.route, event.message.rpc);
}
if(this.shouldQueueRoomPacket(event.message))this.gameSession!.queueRoomPacket(()=>this.dispatchMessage(event.message));
else this.dispatchMessage(event.message);
break;
case 'slow':
case 'reconnecting':
@@ -442,10 +418,12 @@ export class PlatformRuntime {
this.store.setConnectionPhase(event.type);
this.throwObserverDispatchError();
if (this.isTerminal()) return;
this.gameSession?.cancelPendingMount();
this.options.scene.showReconnect();
break;
case 'close':
this.wireIsOpen = false;
this.gameSession?.cancelPendingMount();
break;
}
} catch (error) {
@@ -457,6 +435,30 @@ export class PlatformRuntime {
}
}
private shouldQueueRoomPacket(message:InboundMessage):boolean {
// Preserve Router's authentication barrier at receipt time, before a later mount can finish.
if(this.session?.loginPending||!this.gameSession?.isRoomScopeAvailable)return false;
if(message.route===this.options.gameEntry.route)return true;
if(message.route==='agent'&&message.rpc==='free_room')return true;
if(message.route!=='room'||!Object.prototype.hasOwnProperty.call(FIRST_SLICE_RPC_ROUTE,message.rpc))return false;
// Entry and connection control packets may invalidate the pending scope immediately.
return !['player_login','create_room','self_join_room','connect_roomserver','connect_agentserver','kick_server','kick_offline'].includes(message.rpc);
}
private dispatchMessage(message:InboundMessage):void {
try {
const stateBeforeDispatch=this.store.getState();
const dispatchResult=this.requireRouter().dispatch(message);
this.throwObserverDispatchError();
if(dispatchResult==='handled-platform'&&this.lifecycle==='running'&&this.store.getState()!==stateBeforeDispatch){
this.updateSceneAfterMessage(message.route,message.rpc);
}
} catch(error) {
// Deferred packet processing keeps observer errors in the same failure chain.
throw this.withObserverDispatchError(error);
}
}
private isStopped(): boolean {
return this.lifecycle === 'stopped';
}
@@ -510,6 +512,8 @@ export class PlatformRuntime {
send: { send(envelope: OutboundEnvelope): void },
): GameHostLease {
const lease = createGameHostAdapter({
feedback:this.options.feedback,
numericTools:this.options.numericTools,
gameEntry: this.options.gameEntry,
store: this.store,
commands,
@@ -549,6 +553,7 @@ export class PlatformRuntime {
return;
}
if (rpc === 'self_exit_room') this.options.scene.showLobby();
if (rpc === 'free_room'&&this.state.room.kind==='outside')this.options.scene.showLobby();
}
private stopForKick(data: unknown): void {
@@ -1,11 +1,12 @@
import type { ServerDenial } from './runtime-session.ts';
import type {RoomScope} from './room/room-scope.ts';
/** Semantic scene intents; the headless runtime never imports Cocos. */
export interface ScenePort {
showLoading(): void;
showLogin(): void;
showLobby(): void;
showRoom(): void;
showRoom(scope:RoomScope): void|PromiseLike<void>;
showReconnect(): void;
showKicked(data: unknown): void;
/** Required when the host supports the server's generic message UI. */
@@ -0,0 +1,73 @@
import {requireArray,requireInteger,requireNumber,requireRecord,requireString} from '../../protocol/contracts/validation.ts';
import type {ActiveDissolution,DissolutionParticipant,DissolutionResult,DissolutionState,InsideRoomState,PlatformState} from './platform-types.ts';
export type DissolutionRpc = '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';
function inside(state:PlatformState):InsideRoomState {
if(state.room.kind!=='inside')throw Error('dissolution requires an occupied room');
return state.room;
}
function participants(state:PlatformState,value:unknown):readonly DissolutionParticipant[]{
const room=inside(state),values=requireArray(value,'agreefree.state');
if(values.length!==room.seatPlayerIds.length)throw Error('agreefree.state length must match server room seats');
const result:DissolutionParticipant[]=[];
values.forEach((value,seat)=>{
const playerid=room.seatPlayerIds[seat];
if(playerid===null){if(value!==null)throw Error(`agreefree.state[${seat}] requires null for empty seat`);return;}
const player=state.players.entities[playerid];
if(!player)throw Error(`players.entities missing occupied seat ${seat}`);
const vote=requireInteger(value,`agreefree.state[${seat}]`);
if(vote!==-1&&vote!==0&&vote!==1&&vote!==2)throw Error('agreefree.state requires -1, 0, 1, or 2');
result.push(Object.freeze({seat,playerid,nickname:player.nickname,state:vote}));
});
return Object.freeze(result);
}
function initiator(players:readonly DissolutionParticipant[]):DissolutionParticipant {
const matches=players.filter(player=>player.state===0);
if(matches.length!==1)throw Error('agreefree.state requires exactly one initiator');
return matches[0];
}
export function restoreDissolution(state:PlatformState,payload:unknown,now:number):ActiveDissolution {
const room=inside(state),raw=requireRecord(payload,'agreefree');
const players=participants(state,raw.state),applicant=initiator(players);
const countdown=requireNumber(raw.countdown,'agreefree.countdown');
if(countdown<0)throw Error('agreefree.countdown requires non-negative seconds');
return Object.freeze({kind:'active',roomcode:room.roomcode,selfSeat:room.selfSeat,participants:players,initiator:applicant,deadline:now+countdown*1000});
}
/** No quorum or timeout calculation is performed here. Every transition is a server packet. */
export function reduceDissolution(state:PlatformState,rpc:DissolutionRpc,payload:unknown,now:number):DissolutionState {
const room=inside(state),raw=requireRecord(payload,'$.data');
if(rpc==='self_apply_free_room'||rpc==='other_apply_free_room'){
const vote=restoreDissolution(state,raw.agreefree,now);
const seat=rpc==='self_apply_free_room'?room.selfSeat:requireInteger(raw.seat,'$.data.seat');
if(vote.initiator.seat!==seat)throw Error('apply seat conflicts with agreefree initiator');
return vote;
}
if(rpc==='free_room'){
// Legacy/actual server omits freeNow for ordinary voting, and emits 1 for immediate dissolution.
if(raw.freeNow!==undefined&&raw.freeNow!==false&&raw.freeNow!==true&&raw.freeNow!==0&&raw.freeNow!==1)throw Error('freeNow requires boolean or protocol flag');
const freeNow=raw.freeNow===true||raw.freeNow===1;
const players=freeNow&&raw.seats===undefined
? state.dissolution?.participants ?? Object.freeze([])
: participants(state,raw.seats);
const applicant=freeNow&&raw.seats===undefined?state.dissolution?.initiator??null:initiator(players);
const result:DissolutionResult={kind:'result',roomcode:room.roomcode,selfSeat:room.selfSeat,participants:players,initiator:applicant,passed:true,refusedSeat:null,freeNow,
...(raw.deskfree!==undefined&&raw.deskfree!==null?{deskfree:requireRecord(raw.deskfree,'$.data.deskfree')}:{}),
...(raw.tips!==undefined?{tips:requireString(raw.tips,'$.data.tips')}:{}),
...(raw.time!==undefined?{time:requireNumber(raw.time,'$.data.time')}:{})};
return Object.freeze(result);
}
const vote=state.dissolution;
if(!vote||vote.kind!=='active')throw Error(`${rpc} requires an active dissolution vote`);
const seat=rpc.startsWith('self_')?room.selfSeat:requireInteger(raw.seat,'$.data.seat');
const voter=vote.participants.find(player=>player.seat===seat);
if(!voter)throw Error(`${rpc} requires a participating seat`);
const refused=rpc==='self_refuse_free_room'||rpc==='other_refuse_free_room';
const players=Object.freeze(vote.participants.map(player=>player.seat===seat?Object.freeze({...player,state:refused?2 as const:1 as const}):player));
if(refused)return Object.freeze({kind:'result',roomcode:vote.roomcode,selfSeat:vote.selfSeat,participants:players,initiator:vote.initiator,passed:false,refusedSeat:seat,freeNow:false});
return Object.freeze({...vote,participants:players});
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "660abd9a-8986-48fb-94f6-e5718b9715b8",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,3 +1,5 @@
import type {RoomSocialMessage,RoomSocialState} from './platform-types.ts';
import {reduceDissolution,restoreDissolution,type DissolutionRpc} from './dissolution.ts';
import type {Roomtype} from '../../sdk/contracts/roomtype.ts';
import type { Unsubscribe } from '../../core/reactive.ts';
import {parseVipRooms} from '../../protocol/contracts/vip-room.ts';
@@ -5,6 +7,7 @@ import {parseRankList,parseVipRankList} from '../../protocol/contracts/rank-list
import type { TaskData } from '../../core/types/task.ts';
import type {LoginAccountIdentity} from '../../protocol/contracts/login-contract.ts';
import {requireStringOrFiniteNumber} from '../../protocol/contracts/validation.ts';
import {parseResponseRoomcode} from '../../protocol/contracts/room-contracts.ts';
import {
hasOwn,
requireArray,
@@ -154,7 +157,7 @@ function verifyRoomtypeSourceGraph(graph: readonly RoomtypeObjectSnapshot[]): vo
}
}
export function cloneAndFreezeRoomtype(value: Roomtype): Roomtype {
export function cloneAndFreezeRoomtype(value: unknown): Roomtype {
const copies = new WeakMap<object, object>();
const active = new WeakSet<object>();
const ownedGraph: object[] = [];
@@ -207,6 +210,7 @@ export function cloneAndFreezeRoomtype(value: Roomtype): Roomtype {
}
arrayLength = requireInteger(lengthProperty.descriptor.value, `${path}.length`);
if (arrayLength < 0) throw new RangeError(`${path}.length: expected non-negative integer`);
if(properties.length!==arrayLength+1)throw new TypeError(path+': sparse array is not JSON data');
target = new Array(arrayLength);
} else {
target = prototype === null ? Object.create(null) as Record<string, unknown> : {};
@@ -291,7 +295,7 @@ function buildRoom(
const selfSeat = requireInteger(room.seat, '$.room.seat');
const roomtype = requireRoomtype(room.roomtype, '$.room.roomtype');
const sourcePlayers = requireArray(room.players, '$.room.players');
if (requireString(raw.roomcode, '$.raw.roomcode') !== roomcode) {
if (parseResponseRoomcode(raw.roomcode, '$.raw.roomcode') !== roomcode) {
throw new RangeError('$.room.roomcode: expected value parsed from raw roomcode');
}
if (requireInteger(raw.seat, '$.raw.seat') !== selfSeat) {
@@ -366,6 +370,7 @@ function buildRoom(
stage,
needprepare,
infinite,
...roomMenuMetadata(raw,infinite),
}),
};
}
@@ -429,15 +434,87 @@ interface PlatformTransition {
}
export class PlatformStore {
private nextSocialId=1;
private state: PlatformState = INITIAL_STATE;
private readonly listeners = new Set<PlatformStateListener>();
private readonly transitions: PlatformTransition[] = [];
private publishing = false;
/** Legacy addBroadcast discards marquee receipts outside the main room scene. */
addRoomBroadcast(text:string):void {
requireString(text,'$.data.msgcontent');
if(this.state.room.kind!=='inside')return;
const prior=this.state.roomBroadcasts;
this.commit(Object.freeze({...this.state,roomBroadcasts:Object.freeze(prior===undefined?[text]:[...prior,text])}));
}
applyRoomSocial(rpc:'send_text'|'send_voice'|'send_phiz',payload:unknown):void {
const room=requireInsideRoom(this.state),raw=requireRecord(payload,'$.data');
// Legacy missing type means ordinary player chat, normalized only at ingress.
const type=raw.type===undefined?0:requireTaskNumber(raw.type,'$.data.type');
const prior=this.state.roomSocial;
const history=prior===undefined?Object.freeze([]):prior.history;
const announcements=prior===undefined?Object.freeze([]):prior.announcements;
if(rpc==='send_text'&&type===1){
const text=requireString(raw.text,'$.data.text');
this.commit(Object.freeze({...this.state,roomSocial:Object.freeze({history,announcements:Object.freeze([...announcements,text])})}));return;
}
if(type!==0&&type!==2&&!(rpc==='send_text'&&type===3))throw Error('Unsupported room social type');
const seat=requireInteger(requireTaskNumber(raw.seat,'$.data.seat'),'$.data.seat');
const playerid=requireOccupiedSeat(room,seat),player=this.state.players.entities[playerid];
if(!player)throw Error('room social sender missing from player directory');
const sender=Object.freeze({...player,seat});
const info=type===2?(cloneAndFreezeRoomtype([requireRecord(raw.info,'$.data.info')]) as readonly Readonly<Record<string,unknown>>[])[0]:undefined;
let message:Omit<RoomSocialMessage,'id'>;
if(rpc==='send_voice'){
const time=requireTaskNumber(raw.time,'$.data.time');if(time<=0)throw Error('voice time must be positive');
message={kind:'voice',sender,voiceurl:requireString(raw.voiceurl,'$.data.voiceurl'),time,autoPlay:type===0};
}else if(rpc==='send_phiz'||type===3){
const index=requirePositiveInteger(requireTaskNumber(raw.text,'$.data.text'),'$.data.text');
message={kind:rpc==='send_phiz'?'expression':'phrase',sender,index};
}else message={kind:'text',sender,text:requireString(raw.text,'$.data.text')};
const social:RoomSocialState=Object.freeze({announcements,history:Object.freeze([...history,Object.freeze({...message,id:this.nextSocialId++,...(info===undefined?{}:{info})})])});
this.commit(Object.freeze({...this.state,roomSocial:social}));
}
changeSeats(payload:unknown):void {
const room=requireInsideRoom(this.state),raw=requireRecord(payload,'$.data');
const seat1=requireInteger(raw.seat1,'$.data.seat1'),seat2=requireInteger(raw.seat2,'$.data.seat2');
for(const seat of [seat1,seat2])if(seat<0||seat>=room.seatPlayerIds.length)throw Error('change_seat outside seat range');
const ids=[...room.seatPlayerIds];[ids[seat1],ids[seat2]]=[ids[seat2],ids[seat1]];
const swap=(seat:number)=>seat===seat1?seat2:seat===seat2?seat1:seat;
this.commit(Object.freeze({...this.state,room:Object.freeze({...room,selfSeat:swap(room.selfSeat),seatPlayerIds:Object.freeze(ids),readySeats:Object.freeze(room.readySeats.map(swap).sort((a,b)=>a-b)),offlineSeats:Object.freeze(room.offlineSeats.map(swap).sort((a,b)=>a-b))})}));
}
getState(): PlatformState {
return this.state;
}
applyDissolution(rpc:DissolutionRpc,payload:unknown,now=Date.now()):void {
// server_room/rpc.js deletes data on self agree/refuse acknowledgements.
// Only these two explicit empty-ACK contracts permit an absent payload.
const raw=payload===undefined&&(rpc==='self_agree_free_room'||rpc==='self_refuse_free_room')
? {} : requireRecord(payload,'$.data');
let dissolution=reduceDissolution(this.state,rpc,raw,now);
if(dissolution.kind==='result'&&dissolution.deskfree!==undefined){
dissolution=Object.freeze({...dissolution,deskfree:(cloneAndFreezeRoomtype([dissolution.deskfree]) as readonly unknown[])[0] as Readonly<Record<string,unknown>>});
}
let players=this.state.players;
if(rpc==='free_room'&&hasOwn(raw,'roomcard')){
const roomcard=requireNumber(raw.roomcard,'$.data.roomcard');
const playerid=players.selfPlayerId;
if(playerid===null||!players.entities[playerid])throw Error('free_room requires authenticated player');
players=Object.freeze({...players,entities:Object.freeze({...players.entities,[playerid]:Object.freeze({...players.entities[playerid],roomcard})})});
}
this.commit(Object.freeze({...this.state,players,dissolution,
...(rpc==='free_room'?{app:Object.freeze({phase:'logged-in' as const})}:{})}));
}
acknowledgeDissolution():void {
if(this.state.dissolution?.kind!=='result')throw Error('dissolution acknowledgement requires a result');
this.commit(Object.freeze({...this.state,dissolution:null}));
}
applyTaskList(payload:unknown):void {
const raw=requireRecord(payload,'$.data');
const ids=new Set<string>();
@@ -644,11 +721,9 @@ export class PlatformStore {
player,
requireProtocolFlag(raw.isbattle, '$.login.raw.isbattle'),
);
return this.commit(Object.freeze({
app: Object.freeze({ phase: 'logged-in' }),
players: roomResult.players,
room: roomResult.room,
}));
const next:PlatformState={app:Object.freeze({phase:'logged-in'}),players:roomResult.players,room:roomResult.room};
const dissolution=raw.agreefree===undefined||raw.agreefree===null?undefined:restoreDissolution(next,raw.agreefree,Date.now());
return this.commit(Object.freeze({...next,...(dissolution?{dissolution}:{})}));
}
createRoom(parsed: ParsedCreateRoomResponse, seatCount: number): PlatformState {
@@ -673,6 +748,8 @@ export class PlatformStore {
seatPlayerIds: Object.freeze(seatPlayerIds), readySeats: Object.freeze([]),
offlineSeats: Object.freeze([]), roomtype: cloneAndFreezeRoomtype(roomtype),
stage: 0, needprepare: parsed.needprepare, infinite: parsed.infinite,
...roomMenuMetadata(parsed.raw,parsed.infinite),
status:1,canexit:1,
}),
}));
}
@@ -719,7 +796,9 @@ export class PlatformStore {
if (reconnectPresent && stage !== 1) {
throw new RangeError('$.response.stage: reconnect room must be in battle stage');
}
const roomResult = buildRoom(input.room, raw, selfPlayer, stage);
const builtRoom = buildRoom(input.room, raw, selfPlayer, stage);
// self_join_room sets ChangeExit(1), then deskwar locks finite rooms.
const roomResult = {...builtRoom,room:Object.freeze({...builtRoom.room,canexit:(builtRoom.room.infinite===1||!raw.deskwar?1:0) as 0|1})};
return this.commit(Object.freeze({
// A switch authenticates with connect_*server, then self_join_room instead of player_login.
app: previous.app.phase === 'logged-in'
@@ -742,6 +821,10 @@ export class PlatformStore {
throw new Error('players.entities: missing authenticated player');
}
this.commit(Object.freeze({
...previous,
dissolution:null,
roomSocial:undefined,
roomBroadcasts:undefined,
app: previous.app,
players: Object.freeze({
selfPlayerId,
@@ -782,6 +865,7 @@ export class PlatformStore {
: [...room.offlineSeats];
this.commit(Object.freeze({
...previous,
app: previous.app,
players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId,
@@ -793,10 +877,24 @@ export class PlatformStore {
readySeats: Object.freeze(readySeats),
offlineSeats: Object.freeze(offlineSeats),
stage,
...(event.raw.deskwar?{canexit:(room.infinite===1?1:0) as 0|1}:{}),
}),
}));
}
/** Desk.self_makewar / Desk.makewar mark seat zero ready before StartWar. */
startRoom(sourceRpc:'self_makewar'|'other_makewar'):void {
const previous=this.getState();const room=requireInsideRoom(previous);
const ownerId=requireOccupiedSeat(room,0);const owner=previous.players.entities[ownerId];
if(owner===undefined)throw new Error('Start requires seat zero player');
const readySeats=room.readySeats.includes(0)?room.readySeats:Object.freeze([...room.readySeats,0]);
const selfReady=sourceRpc==='self_makewar'||readySeats.includes(room.selfSeat);
const canexit=room.infinite===1?1:(room.needprepare!==1||selfReady?0:room.canexit);
this.commit(Object.freeze({...previous,
players:Object.freeze({...previous.players,entities:Object.freeze({...previous.players.entities,[ownerId]:Object.freeze({...owner,isprepare:1})})}),
room:Object.freeze({...room,stage:1,readySeats,canexit})}));
}
playerReady(parsed: ParsedSeatPayload): void {
const event = parseSeatAction(parsed);
const previous = this.getState();
@@ -816,12 +914,13 @@ export class PlatformStore {
});
const readySeats = Object.freeze([...room.readySeats, event.seat]);
this.commit(Object.freeze({
...previous,
app: previous.app,
players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId,
entities,
}),
room: Object.freeze({ ...room, readySeats, stage }),
room: Object.freeze({ ...room, readySeats, stage,...(event.raw.deskwar?{canexit:(room.infinite===1?1:0) as 0|1}:{}) }),
}));
}
@@ -843,6 +942,7 @@ export class PlatformStore {
});
const offlineSeats = Object.freeze([...room.offlineSeats, event.seat]);
this.commit(Object.freeze({
...previous,
app: previous.app,
players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId,
@@ -877,6 +977,7 @@ export class PlatformStore {
room.offlineSeats.filter((seat) => seat !== event.seat),
);
this.commit(Object.freeze({
...previous,
app: previous.app,
players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId,
@@ -905,6 +1006,7 @@ export class PlatformStore {
const offlineSeats = room.offlineSeats.filter((seat) => seat !== event.seat);
this.commit(Object.freeze({
...previous,
app: previous.app,
players: Object.freeze({
selfPlayerId: previous.players.selfPlayerId,
@@ -919,3 +1021,15 @@ export class PlatformStore {
}));
}
}
/** Missing optional wire metadata stays absent; legacy derivations live only here. */
function roomMenuMetadata(raw:Readonly<Record<string,unknown>>,infinite:number){
return {
ownerNotice:raw.ownerNotice===undefined||raw.ownerNotice===null||raw.ownerNotice===''?null:requireString(raw.ownerNotice,'ownerNotice'),
...(raw.roommode===undefined?{}:{roomMode:requireInteger(requireTaskNumber(raw.roommode,'roommode'),'roommode')}),
...(raw.isowner===undefined?{}:{status:(requireProtocolFlag(raw.isowner,'isowner')===1?1:2) as 1|2}),
...(infinite===1?{canexit:1 as const}:raw.isbet===undefined?{}:{canexit:(requireTaskNumber(raw.isbet,'isbet')===0?1:0) as 0|1}),
shortcode:raw.shortcode===undefined||raw.shortcode===null?null:requireStringOrFiniteNumber(raw.shortcode,'shortcode'),
matchId:raw.matchid===undefined||raw.matchid===null?null:requireStringOrFiniteNumber(raw.matchid,'matchid'),
};
}
@@ -41,6 +41,13 @@ export interface OutsideRoomState {
}
export interface InsideRoomState {
/** Desk.setOwnerNotice accepts nonempty source text; absent/empty means no notice. */
readonly ownerNotice?:string|null;
readonly roomMode?:number;
readonly status?:1|2;
readonly canexit?:0|1;
readonly shortcode?:string|number|null;
readonly matchId?:string|number|null;
readonly kind: 'inside';
readonly roomcode: string;
readonly selfSeat: number;
@@ -55,7 +62,59 @@ export interface InsideRoomState {
export type RoomState = OutsideRoomState | InsideRoomState;
/** Server agreefree/seats encoding: null is an empty seat, 0 is the initiator. */
export interface DissolutionParticipant {
readonly seat:number;
readonly playerid:number;
readonly nickname:string;
readonly state:-1|0|1|2;
}
interface DissolutionBase {
readonly roomcode:string;
readonly selfSeat:number;
readonly participants:readonly DissolutionParticipant[];
}
export interface ActiveDissolution extends DissolutionBase {
readonly kind:'active';
readonly initiator:DissolutionParticipant;
/** Epoch milliseconds for display only; the server owns timeout/default votes. */
readonly deadline:number;
}
export interface DissolutionResult extends DissolutionBase {
readonly kind:'result';
readonly initiator:DissolutionParticipant|null;
readonly passed:boolean;
readonly refusedSeat:number|null;
readonly freeNow:boolean;
readonly deskfree?:Readonly<Record<string,unknown>>;
readonly tips?:string;
readonly time?:number;
}
export type DissolutionState = ActiveDissolution|DissolutionResult;
export interface RoomSocialMessage {
/** Client-local immutable identity; never transmitted to the server. */
readonly id:number;
readonly kind:'text'|'phrase'|'voice'|'expression';
readonly sender:Readonly<PlatformPlayer & {seat:number}>;
readonly text?:string;
readonly index?:number;
/** Defined for voice: ordinary messages auto-play; robot messages are history-only. */
readonly autoPlay?:boolean;
readonly voiceurl?:string;
readonly time?:number;
readonly info?:Readonly<Record<string,unknown>>;
}
export interface RoomSocialState {
readonly history:readonly RoomSocialMessage[];
readonly announcements:readonly string[];
}
export interface PlatformState {
/** Ordered broadcast/msgtype=1 receipts; absent means no room broadcasts received. */
readonly roomBroadcasts?:readonly string[];
readonly roomSocial?:RoomSocialState;
/** Absent before any vote; null after local result acknowledgement. */
readonly dissolution?:DissolutionState|null;
readonly vipRank?:{readonly entries:readonly import('../../protocol/contracts/rank-list.ts').RankEntry[]|null};
readonly treasureList?:readonly import('../../protocol/contracts/rank-list.ts').RankEntry[];
/** Receipt identity allows repeated queries with an unchanged code to open the view. */
@@ -0,0 +1,50 @@
import type {NumericToolsPort} from '../sdk/contracts/game-capabilities.ts';
type InputRequest=Parameters<NumericToolsPort['openNumberInput']>[0];
type MultiplierRequest=Parameters<NumericToolsPort['showMultiplier']>[0];
type PendingRequest={kind:'input';request:InputRequest}|{kind:'multiplier';request:MultiplierRequest};
/** Defers game attach/restore requests until the destination room Canvas is ready. */
export class DeferredNumericToolsPort implements NumericToolsPort {
private target:NumericToolsPort|null=null;
private readonly pending=new Map<PendingRequest['kind'],PendingRequest>();
bind(target:NumericToolsPort):void {
this.target=target;
while(this.target===target&&this.pending.size>0){
const next=this.pending.values().next().value as PendingRequest;
this.pending.delete(next.kind);
if(next.kind==='input')target.openNumberInput(next.request);
else target.showMultiplier(next.request);
}
}
/** A scene transition drops the old view, retaining requests queued for its replacement. */
unbind():void {this.target=null;}
openNumberInput(request:InputRequest):void {
if(this.target!==null){this.target.openNumberInput(request);return;}
this.enqueue({kind:'input',request});
}
closeNumberInput():void {
const pending=this.pending.get('input');this.pending.delete('input');pending?.request.onCancel?.();this.target?.closeNumberInput();
}
showMultiplier(request:MultiplierRequest):void {
if(this.target!==null){this.target.showMultiplier(request);return;}
this.enqueue({kind:'multiplier',request});
}
hideMultiplier():void {
const pending=this.pending.get('multiplier');this.pending.delete('multiplier');pending?.request.onCancel?.();this.target?.hideMultiplier();
}
/** Lease invalidation also releases queued callbacks and prevents new work reaching an old view. */
close():void {
const pending=[...this.pending.values()];this.pending.clear();
const target=this.target;this.unbind();const errors:unknown[]=[];
for(const entry of pending){try{entry.request.onCancel?.();}catch(error){errors.push(error);}}
try{target?.close();}catch(error){errors.push(error);}
if(errors.length===1)throw errors[0];
if(errors.length>1)throw Object.assign(new Error('Numeric tools cleanup failed'),{errors:Object.freeze(errors)});
}
private enqueue(pending:PendingRequest):void {
// Replacing a request moves it to the end, preserving which tool should be on top.
const previous=this.pending.get(pending.kind);this.pending.delete(pending.kind);previous?.request.onCancel?.();this.pending.set(pending.kind,pending);
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "0c3a8ee5-c1b7-4694-a926-26fae90f5e55",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,9 @@
import type {PlatformGameSnapshot} from '../sdk/contracts/snapshots.ts';
import type {GameRoomProjection} from '../sdk/contracts/game-room-projection.ts';
/** Keep authority and game facts separate; composition never mutates either owner. */
export function composeGameRoomPresentation(platform:PlatformGameSnapshot,game:GameRoomProjection|null){
return Object.freeze({platform,game});
}
export {gameAllowsRoomAction} from '../sdk/room-action-policy.ts';
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "d30b033d-a7fc-4e44-8d16-b342d0b3e3af",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,17 @@
export interface KeyedRowPort<T,R>{key(item:T):number;create(item:T,index:number):R;update(row:R,item:T,index:number):void;remove(row:R):void;}
/** Stable identity belongs to the source; this reconciler never derives keys from content. */
export class KeyedRows<T,R>{
private readonly rows=new Map<number,{item:T;row:R;index:number}>();
constructor(private readonly port:KeyedRowPort<T,R>){}
get(key:number):R|undefined{return this.rows.get(key)?.row;}
render(items:readonly T[]):void{
const keys=new Set<number>();
for(const item of items){const key=this.port.key(item);if(keys.has(key))throw Error('Duplicate row ID '+key);keys.add(key);}
for(const [key,value]of this.rows)if(!keys.has(key)){this.rows.delete(key);this.port.remove(value.row);}
items.forEach((item,index)=>{const key=this.port.key(item),prior=this.rows.get(key);
if(!prior)this.rows.set(key,{item,index,row:this.port.create(item,index)});
else if(prior.item!==item||prior.index!==index){this.port.update(prior.row,item,index);prior.item=item;prior.index=index;}
});
}
dispose():void{const errors:unknown[]=[];for(const value of this.rows.values())try{this.port.remove(value.row);}catch(error){errors.push(error);}this.rows.clear();if(errors.length)throw Object.assign(Error('Row cleanup failed'),{errors});}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "d565fcd4-07ea-4269-a7d3-6fae0c3f4266",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,61 @@
export interface MultiplierOptions {
readonly originalValues:readonly number[];
readonly onConfirm:(values:number[])=>void;
readonly storage:Pick<Storage,'getItem'|'setItem'>;
readonly gameid:string|number;
readonly agentid:string|number;
}
/** Layer607 calculator owns the legacy saved preference's default/repair semantics. */
export class MultiplierModel {
private value:string;
private readonly originalValues:readonly number[];
private readonly key:string;
constructor(private readonly options:MultiplierOptions){
if(!Array.isArray(options.originalValues)||!options.originalValues.every(Number.isFinite))throw TypeError('Calculator requires finite original values');
if(typeof options.onConfirm!=='function')throw TypeError('Calculator requires a result callback');
this.originalValues=[...options.originalValues];
this.key=`tsgame_multiple_${options.gameid}${options.agentid}`;
const saved=options.storage.getItem(this.key);
if(saved!==null&&/^\d+(?:\.\d*)?$/.test(saved)&&Number.isFinite(Number(saved))&&Number(saved)>0){
this.value=String(Number(saved)).replace('.','b');
}else{
this.value='1';options.storage.setItem(this.key,'1');
}
}
get text():string{return this.value;}
get multiplier():number{return Number(this.value.replace('b','.'));}
inputDigit(digit:number):string|null {
if(!Number.isInteger(digit)||digit<0||digit>9)throw RangeError('Invalid multiplier digit');
return this.append(String(digit));
}
inputDecimal():string|null{return this.append('b');}
backspace():void{this.value=this.value.length>1?this.value.slice(0,-1):'0';}
confirm():number[] {
const multiplier=this.multiplier===0?1:this.multiplier;
const result=this.originalValues.map(value=>decimalMultiply(value,multiplier));
this.options.storage.setItem(this.key,String(multiplier));
this.options.onConfirm(result);
this.value=String(multiplier).replace('.','b');
return result;
}
private append(input:string):string|null {
const next=this.value==='0'&&input!=='b'?input:this.value+input;
if(next.length>8)return '输入长度超过限制!';
if(!/^\d+(?:b\d*)?$/.test(next))return '输入不正确!';
this.value=next;return null;
}
}
/** Integer coefficient arithmetic retains Func.accMul's decimal precision, including exponent notation. */
function decimalMultiply(left:number,right:number):number {
const parts=(value:number):{coefficient:number;exponent:number}=>{
const [mantissa,exponentText]=String(value).split('e');
const decimals=mantissa.includes('.')?mantissa.length-mantissa.indexOf('.')-1:0;
return {coefficient:Number(mantissa.replace('.','')),exponent:(exponentText===undefined?0:Number(exponentText))-decimals};
};
const a=parts(left),b=parts(right);
const result=Number(`${a.coefficient*b.coefficient}e${a.exponent+b.exponent}`);
if(!Number.isFinite(result))throw RangeError('Calculator result exceeds finite number range');
return result;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "104f8058-384f-4221-bfa0-aff820f7ebe6",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,36 @@
export interface NumericInputOptions {
readonly message:string;
/** Legacy openInputPanel omitting begin0 selects numeric mode. */
readonly begin0?:boolean;
readonly onConfirm:(value:string|number)=>void;
}
/** Layer602 input state. Confirmation intentionally leaves dismissal to its caller. */
export class NumericInputModel {
private value:string;
constructor(private readonly options:NumericInputOptions){
if(typeof options.message!=='string'||typeof options.onConfirm!=='function')throw TypeError('Invalid numeric input request');
this.value=options.begin0?'':'0';
}
get message():string{return this.options.message;}
get text():string{return this.value;}
inputDigit(digit:number):string|null {
if(!Number.isInteger(digit)||digit<0||digit>9)throw RangeError('Invalid input digit');
if(this.value.length>=18)return '输入超出范围';
this.value=!this.options.begin0&&this.value==='0'?String(digit):this.value+digit;
return null;
}
backspace():void {
this.value=this.value.length===1?(this.options.begin0?'':'0'):this.value.slice(0,-1);
}
paste(text:string):string|null {
if(!text)return null;
const digits=text.replace(/[^0-9]/g,'');
if(digits.length>18)return '输入超出范围';
this.value=digits;return null;
}
confirm():string|number {
const result=this.options.begin0?this.value:Number(this.value);
this.options.onConfirm(result);return result;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "70c4afff-ffe3-4598-899f-f07c4d80e1b8",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,36 @@
import type {FeedbackOwnerPort,OwnerHandle} from '../sdk/contracts/game-capabilities.ts';
/** One arbiter for the real loading/tip surface, including existing platform callers. */
export class OwnedFeedback implements FeedbackOwnerPort {
private disposed=false;
private platformLoading=false;
private readonly loadingOwners=new Set<object>();
private tipOwner:object|null=null;
constructor(private readonly render:{loading(show:boolean):void;tip(message:string,time:number):void;closeTip():void}){}
setPlatformLoading(show:boolean):void{this.active();this.platformLoading=show;this.render.loading(show||this.loadingOwners.size>0);}
beginLoading():OwnerHandle {
this.active();const owner={};this.loadingOwners.add(owner);
try{this.render.loading(true);}catch(error){this.loadingOwners.delete(owner);throw error;}
return Object.freeze({close:()=>{if(!this.loadingOwners.delete(owner)||this.disposed)return;this.render.loading(this.platformLoading||this.loadingOwners.size>0);}});
}
showBusinessTip(message:string,time:number):OwnerHandle {
if(!Number.isFinite(time))throw TypeError('Business tip requires finite display time');
return this.showPlatformTip(message,time);
}
/** Legacy show_message uses Number(time): non-positive/NaN means no automatic dismissal. */
showPlatformTip(message:string,time:number):OwnerHandle {
this.active();if(typeof message!=='string'||message.length===0||typeof time!=='number')throw TypeError('Business tip requires text and display time');
const owner={};this.tipOwner=owner;
try{this.render.tip(message,time);}catch(error){if(this.tipOwner===owner)this.tipOwner=null;throw error;}
return Object.freeze({close:()=>{if(this.disposed||this.tipOwner!==owner)return;this.tipOwner=null;this.render.closeTip();}});
}
dismissTip():void{if(this.disposed)return;this.tipOwner=null;this.render.closeTip();}
clear():void {
this.platformLoading=false;this.loadingOwners.clear();this.tipOwner=null;const errors:unknown[]=[];
try{this.render.loading(false);}catch(error){errors.push(error);}
try{this.render.closeTip();}catch(error){errors.push(error);}
if(errors.length)throw Object.assign(new Error('Feedback cleanup failed'),{errors:Object.freeze(errors)});
}
dispose():void{if(this.disposed)return;this.disposed=true;this.clear();}
private active():void{if(this.disposed)throw Error('Feedback owner is disposed');}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4579cc6c-b016-4b55-902c-35887b3dce31",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,33 @@
import {callPlatformSetting,type PlatformNativeHost} from '../adapters/native/platform-native.ts';
export type PlatformSetting='bgm'|'sound'|'shake'|'voice';
/** Utl.Config + Logic setting actions. The source defines absent preferences as on. */
export class PlatformSettings {
private readonly keys:Record<PlatformSetting,string>;
constructor(private readonly storage:Pick<Storage,'getItem'|'setItem'>,gameid:string|number,agentid:string|number,
private readonly host:PlatformNativeHost){
this.keys={bgm:`tsgame_bgm_${gameid}`,sound:`tsgame_sound_${gameid}${agentid}`,
shake:`tsgame_shake_${gameid}${agentid}`,voice:`tsgame_voice_${gameid}${agentid}`};
}
read(setting:PlatformSetting):boolean {
const key=this.keys[setting],raw=this.storage.getItem(key);
if(raw===null){this.storage.setItem(key,'1');return true;}
if(raw!=='0'&&raw!=='1')throw Error(`Invalid ${key}`);
return raw==='1';
}
/** Logic initialization reapplies the saved voice preference to each native session. */
synchronizeVoice():void {callPlatformSetting(this.host,'voicePlaying',this.read('voice')?1:0);}
toggle(setting:PlatformSetting):boolean {
const on=!this.read(setting);
this.storage.setItem(this.keys[setting],on?'1':'0');
if(setting==='voice')callPlatformSetting(this.host,'voicePlaying',on?1:0);
if(setting==='sound')callPlatformSetting(this.host,'SwitchShake',on?1:0);
if(setting==='shake'){
// The lobby has no active game shake action (legacy shakeID == nil).
if(on)callPlatformSetting(this.host,'SwitchShake',this.read('sound')?1:0);
else callPlatformSetting(this.host,'stopshake');
}
return on;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "26177acb-fa14-427a-abfe-716837c7bba0",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,25 @@
export interface ResourceLease<T>{readonly value:Promise<T>;release():void;}
interface Entry<T>{promise:Promise<T>;refs:number;value?:T;loaded:boolean;}
export class ResourceLeaseCache<T>{
private readonly entries=new Map<string,Entry<T>>();
constructor(private readonly options:{idleCapacity:number;load:(key:string)=>Promise<T>;destroy:(value:T)=>void}){
if(!Number.isSafeInteger(options.idleCapacity)||options.idleCapacity<0)throw Error('Invalid idle resource capacity');
}
get size():number{return this.entries.size;}
get leases():number{let count=0;for(const e of this.entries.values())count+=e.refs;return count;}
acquire(key:string):ResourceLease<T>{
let entry=this.entries.get(key);
if(!entry){
const fresh:Entry<T>={refs:0,loaded:false,promise:undefined as unknown as Promise<T>};entry=fresh;this.entries.set(key,fresh);
try{fresh.promise=this.options.load(key).then(value=>{fresh.loaded=true;fresh.value=value;this.trim();return value;},error=>{if(this.entries.get(key)===fresh)this.entries.delete(key);throw error;});}
catch(error){this.entries.delete(key);throw error;}
}
entry.refs++;let released=false;const owned=entry;
return {value:entry.promise,release:()=>{if(released)return;released=true;owned.refs--;this.trim();}};
}
clear():void{for(const [key,entry]of this.entries)if(entry.refs===0&&entry.loaded){this.entries.delete(key);this.options.destroy(entry.value as T);}}
private trim():void{
const idle=[...this.entries].filter(([,e])=>e.refs===0&&e.loaded);
for(let i=0;i<idle.length-this.options.idleCapacity;i++){const [key,entry]=idle[i];this.entries.delete(key);this.options.destroy(entry.value as T);}
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "01f41cce-b7ad-4441-b4fa-128811f7f70f",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,46 @@
import {roomChatByteLength} from './room-chat-config.ts';
/** Game_Surface_3 02_Const.Broadcast: pixels per byte and pixels per millisecond. */
export const ROOM_BROADCAST = Object.freeze({textWidth:18,speed:100});
export interface RoomBroadcastFrame {
readonly text:string;
readonly textWidth:number;
readonly offsetX:number;
readonly durationSeconds:number;
readonly elapsedSeconds:number;
}
/** Layer402/390 marquee. Its owner admits messages only while in the room. */
export class RoomBroadcastQueue {
private readonly pending:string[]=[];
private frame:RoomBroadcastFrame|null=null;
constructor(private readonly clipWidth:number){
if(!Number.isFinite(clipWidth)||clipWidth<=0)throw Error('Broadcast clip width must be positive');
}
get current():RoomBroadcastFrame|null{return this.frame;}
enqueue(text:string):void {
if(typeof text!=='string')throw Error('Broadcast text must be a string');
this.pending.push(text);
if(this.frame===null)this.startNext();
}
advance(deltaSeconds:number):void {
if(!Number.isFinite(deltaSeconds)||deltaSeconds<0)throw Error('Broadcast elapsed time must be nonnegative');
while(this.frame!==null){
const remaining=this.frame.durationSeconds-this.frame.elapsedSeconds;
if(deltaSeconds<remaining){
const elapsedSeconds=this.frame.elapsedSeconds+deltaSeconds;
this.frame=Object.freeze({...this.frame,elapsedSeconds,offsetX:this.clipWidth-elapsedSeconds*ROOM_BROADCAST.speed});
return;
}
deltaSeconds-=remaining;
this.startNext();
}
}
clear():void {this.pending.length=0;this.frame=null;}
private startNext():void {
const text=this.pending.shift();
if(text===undefined){this.frame=null;return;}
const textWidth=roomChatByteLength(text)*ROOM_BROADCAST.textWidth;
this.frame=Object.freeze({text,textWidth,offsetX:this.clipWidth,durationSeconds:(textWidth+this.clipWidth)/ROOM_BROADCAST.speed,elapsedSeconds:0});
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c22a2ea0-b39e-4b44-b771-70700994da31",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,30 @@
import type {RemoteConfig} from '../config/remote-config.ts';
import type {RoomChatConfig,RoomChatPhrase} from '../sdk/contracts/room-chat.ts';
export type {RoomChatConfig,RoomChatPhrase} from '../sdk/contracts/room-chat.ts';
/** Incoming send_text type 3 uses a 1-based phrase index; outgoing quick chat uses text. */
export function resolveRoomChatPhrase(config:RoomChatConfig,index:number):RoomChatPhrase {
if(!Number.isInteger(index)||index<1||config.phrases.length===0)throw Error(`Invalid room chat phrase index: ${index}`);
return config.phrases[(index-1)%config.phrases.length];
}
/** Game_Surface_3 Func.gblen, for visual text measurement only. */
export function roomChatByteLength(text:string):number {
let length=0;
for(let index=0;index<text.length;index++)length+=text.charCodeAt(index)>127||text.charCodeAt(index)===94?2:1;
return length;
}
export function validateRoomChatText(config:RoomChatConfig,text:string,announcement=false):void {
if(typeof text!=='string')throw Error('Invalid chat text');
if(text===''||/^[ ]+$/.test(text))throw Error('请输入聊天内容');
const limit=announcement?config.announcementLimit:config.limitLength;
if(text.length>limit)throw Error(`聊天内容不能超过${limit}个字符`);
if(config.bannedWords.some(word=>word!==''&&text.indexOf(word)>=0))throw Error('内容含有敏感词汇请重新输入!');
}
/** Source defaults from 12_Logic.getConfig_Succ, not consumer fallbacks. */
export function resolveRoomChatPolicy(remote:RemoteConfig):{noticeLimit:number;noticeCost:number} {
const limit=remote.getValue('noticelimit'),cost=remote.getValue('noticecost');
const noticeLimit=limit==null?-1:limit,noticeCost=cost?cost:0;
if(typeof noticeLimit!=='number'||!Number.isFinite(noticeLimit))throw Error('Invalid noticelimit');
if(typeof noticeCost!=='number'||!Number.isFinite(noticeCost))throw Error('Invalid noticecost');
return {noticeLimit,noticeCost};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "86b1833e-8e0d-490d-9431-aa99238ecb78",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1 @@
export {roomExitAction} from '../sdk/room-exit-action.ts';
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c70435c2-745c-48aa-9615-8bc7bfc6ee3d",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,28 @@
import type {InsideRoomState} from '../platform/stores/platform-types.ts';
export function roomHudCycles(room:Pick<InsideRoomState,'infinite'|'matchId'>):boolean {
return room.infinite!==1&&!room.matchId;
}
export function roomHudTime(now:Date):{hours:string;minutes:string} {
if(!Number.isFinite(now.getTime()))throw Error('Invalid room HUD clock date');
return {hours:String(now.getHours()).padStart(2,'0'),minutes:String(now.getMinutes()).padStart(2,'0')};
}
/** 11_GameUI playTopInfoAnimation and timer 3, driven by the Cocos update clock. */
export class RoomHudClock {
showRoomCode=false;
colonOpacity:255|100=255;
private seconds=0;
private cycleSeconds=0;
reset():void{this.seconds=0;this.cycleSeconds=0;this.showRoomCode=false;this.colonOpacity=255;}
update(dt:number,visible:boolean,cycles:boolean):void {
if(!Number.isFinite(dt)||dt<0)throw Error('Invalid room HUD elapsed time');
if(!visible)return;
this.seconds+=dt;
const ticks=Math.floor(this.seconds);this.seconds-=ticks;
if(ticks%2!==0)this.colonOpacity=this.colonOpacity===255?100:255;
if(!cycles){this.showRoomCode=false;this.cycleSeconds=0;return;}
this.cycleSeconds+=dt;
const cycleTicks=Math.floor(this.cycleSeconds/5);this.cycleSeconds-=cycleTicks*5;
if(cycleTicks%2!==0)this.showRoomCode=!this.showRoomCode;
}
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "15fa0855-2809-472f-b634-b8daada8ac90",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,32 @@
import type {RuntimeConfig} from '../config/runtime-config.ts';
import type {PlatformState} from '../platform/stores/platform-types.ts';
import {selectSelfPlayer} from '../platform/stores/selectors.ts';
import {resolveLobbyPolicy} from './lobby-model.ts';
import {resolveSharePolicy} from './share-model.ts';
import {buildRoomMenuModel,type RoomMenuContext} from './room-menu-model.ts';
import type {GameDefinition} from '../sdk/contracts/game-definition.ts';
/** Call when opening Layer413; missing protocol metadata must not stop the HUD. */
export function resolveRoomMenuContext(state:PlatformState,config:RuntimeConfig,policy:GameDefinition['roomMenu'],
host:{h5Version:number;versionState:number}):RoomMenuContext {
if(state.room.kind!=='inside')throw Error('Room menu requires inside room');
if(!policy||typeof policy.mainSceneButton!=='boolean'||typeof policy.vipInfinite!=='boolean')throw Error('Invalid game room menu policy');
const room=state.room,self=selectSelfPlayer(state);
if(!self)throw Error('Room menu requires self player');
for(const key of ['status','canexit','roomMode','shortcode','matchId'] as const){
if(room[key]===undefined)throw Error(`Missing authoritative room source: ${key}`);
}
const rawHideEscape=config.remoteConfig.getValue('hideEscape');
// 04_Data.sysConfig.hideEscape starts at zero; only non-null remote overrides.
const hideEscape=rawHideEscape==null?false:Boolean(rawHideEscape);
const lobby=resolveLobbyPolicy(config.remoteConfig),share=resolveSharePolicy(config);
const context:RoomMenuContext={
status:room.status!,canexit:room.canexit!,roomMode:room.roomMode!,shortcode:room.shortcode!,matchId:room.matchId!,
stage:room.stage,infinite:room.infinite,playercnt:room.seatPlayerIds.filter(id=>id!==null).length,
...host,appdownload:share.url,hideEscape,...policy,
roomCardName:lobby.roomCardName,starName:lobby.starName,bean:self.bean,
};
buildRoomMenuModel(context);
return context;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "9e2e7f5f-9a36-42ff-9ba1-7e25e62f06f7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,68 @@
/** Pure projection of Game_Surface_3/11_GameUI.js openMainSceneBtnPanel.
* All legacy source fields must be resolved before opening the panel. */
export interface RoomMenuContext {
readonly status:number; readonly canexit:number; readonly stage:number;
readonly playercnt:number; readonly roomMode:number; readonly infinite:number;
readonly shortcode:string|number|null; readonly matchId:string|number|null;
readonly h5Version:number; readonly appdownload:string;
readonly hideEscape:boolean; readonly mainSceneButton:boolean; readonly vipInfinite:boolean;
readonly versionState:number; readonly roomCardName:string; readonly bean:number; readonly starName:string;
}
export type RoomMenuSpiritId=151|150|185|233|3014|513|656;
export interface RoomMenuButton {readonly spiritId:RoomMenuSpiritId;readonly label:string;readonly y:number;}
export type RoomMenuAction =
| {readonly kind:'command';readonly command:'room.exit'|'room.apply-dissolution'|'room.change';readonly message?:never}
| {readonly kind:'confirm';readonly checkType:1|2|3;readonly message:string;readonly command:'room.exit'|'room.break'|'room.escape';readonly count?:number}
| {readonly kind:'input'|'tip';readonly message:string}
| {readonly kind:'panel';readonly panel:'settings'|'help';readonly message?:never}
| {readonly kind:'download';readonly url:string;readonly message?:never};
export function buildRoomMenuModel(context:RoomMenuContext):readonly RoomMenuButton[] {
for(const field of ['status','canexit','stage','playercnt','roomMode','infinite','h5Version','versionState','bean'] as const){
if(!Number.isFinite(context[field]))throw Error(`Missing or invalid room menu source: ${field}`);
}
for(const field of ['hideEscape','mainSceneButton','vipInfinite'] as const){
if(typeof context[field]!=='boolean')throw Error(`Missing or invalid room menu source: ${field}`);
}
for(const field of ['appdownload','roomCardName','starName'] as const){
if(typeof context[field]!=='string')throw Error(`Missing or invalid room menu source: ${field}`);
}
for(const field of ['shortcode','matchId'] as const){
if(context[field]!==null&&typeof context[field]!=='string'&&typeof context[field]!=='number')throw Error(`Missing or invalid room menu source: ${field}`);
}
if(context.canexit!==0&&context.canexit!==1)throw Error('Invalid room menu source: canexit');
// The original first hides dissolution for matchId/infinite, then overwrites
// it in the stage branch; infinite is the final override. Preserve that order.
const exit=context.infinite===1||context.stage!==1||context.canexit===1;
const dissolve=context.infinite!==1&&(context.stage===1?context.canexit===0:context.status===1);
const escape=context.status===1&&context.playercnt===2&&context.roomMode===1&&context.infinite===0&&context.stage===0&&!context.mainSceneButton&&!context.hideEscape;
const buttons:readonly [RoomMenuSpiritId,string,boolean][]=[
[151,'设置',true],[150,'帮助',true],[185,'解散',dissolve],[233,'退出',exit],
[3014,'换房',context.infinite===1&&!context.shortcode&&!context.vipInfinite],
[513,'逃跑',escape],[656,'下载',context.h5Version===1&&Boolean(context.appdownload)],
];
return buttons.filter(([, ,visible])=>visible).map(([spiritId,label],index)=>({spiritId,label,y:75+93*index}));
}
export function roomMenuAction(spiritId:RoomMenuSpiritId,context:RoomMenuContext):RoomMenuAction {
if(!buildRoomMenuModel(context).some(button=>button.spiritId===spiritId))throw Error(`Room menu button ${spiritId} is not visible`);
switch(spiritId){
case 151:return {kind:'panel',panel:'settings'};
case 150:return {kind:'panel',panel:'help'};
case 233:return context.infinite===0&&context.status===1
?{kind:'confirm',checkType:2,message:'房间仍存在,是否退出?',command:'room.exit'}
:{kind:'command',command:'room.exit'};
case 185:return context.stage===0
?{kind:'confirm',checkType:1,message:context.versionState===1?' 是否解散房间?':`解散房间不扣${context.roomCardName},是否解散?`,command:'room.break'}
:{kind:'command',command:'room.apply-dissolution'};
case 3014:return {kind:'command',command:'room.change'};
case 513:return {kind:'input',message:'请输入投降扣除数量(底翻)'};
case 656:return {kind:'download',url:context.appdownload};
}
}
export function roomEscapeConfirmation(context:RoomMenuContext,count:number):RoomMenuAction {
if(!Number.isFinite(count)||count<0)throw Error('Invalid surrender count');
if(count>context.bean)return {kind:'tip',message:`输入大于您拥有的${count}${context.starName}数:${context.bean}!`};
return {kind:'confirm',checkType:3,message:`将扣除${count}${context.starName},确定逃跑?`,command:'room.escape',count};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "087800ef-8c3f-4a0f-b81f-630fcbd69a4c",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,22 @@
/** Logic.getVisibleNote + Utl.Config.ownerNoteTime from Game_Surface_3.
* Call only for a nonempty room owner notice. Manual opening bypasses this check. */
export function shouldShowOwnerNotice(
storage:Pick<Storage,'getItem'|'setItem'>,gameid:string,agentid:string,now:number,
):boolean {
if(!Number.isSafeInteger(now)||now<0)throw Error('Invalid owner notice now');
if(typeof gameid!=='string'||gameid===''||typeof agentid!=='string'||agentid==='')throw Error('Invalid owner notice identity');
const key=`tsgame_ownerNoteTime_${gameid}${agentid}`;
const stored=storage.getItem(key);
let expiry=0;
// Missing/empty storage and zero have the source's explicit first-show meaning.
if(stored!==null&&stored!==''){
if(!/^\d+$/.test(stored))throw Error('Invalid owner notice expiry');
expiry=Number(stored);
if(!Number.isSafeInteger(expiry))throw Error('Invalid owner notice expiry');
}
if(expiry!==0&&now<expiry)return false;
const next=now+24*60*60*1000;
if(!Number.isSafeInteger(next))throw Error('Invalid owner notice next expiry');
storage.setItem(key,String(next));
return true;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8ff34190-44a3-4c8f-8638-8fca68bdc940",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -39,8 +39,19 @@ export function reconnectFrom(raw: JsonRecord): ReconnectSnapshot {
return { present: true, value: raw.deskinfo };
}
/** 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 = requireString(raw.roomcode, '$.data.roomcode');
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': {
@@ -0,0 +1,49 @@
import type {GameSeatMapper} from './game-seat-mapper.ts';
import type {PlatformGameSnapshot} from './snapshots.ts';
import type {JsonValue} from './roomtype.ts';
export type InputCancelReason = 'user' | 'scope-ended' | 'replaced';
export type InputResult<T> = {readonly kind:'confirmed';readonly value:T}
| {readonly kind:'cancelled';readonly reason:InputCancelReason};
export interface OwnerHandle {close():void;}
export interface FeedbackOwnerPort {
beginLoading():OwnerHandle;
/** Player-facing business text only; diagnostics have no route through this port. */
showBusinessTip(message:string,time:number):OwnerHandle;
}
export interface RoomCapabilities {
readonly room: {
readonly seat:GameSeatMapper;
getSnapshot():PlatformGameSnapshot;
subscribe(listener:(snapshot:PlatformGameSnapshot)=>void):()=>void;
prepare():void;
requestExit():void;
applyDissolution():void;
};
readonly messages:{send(rpc:string,data:JsonValue):void};
readonly ui:{
beginLoading():OwnerHandle;
showBusinessTip(message:string,time:number):OwnerHandle;
requestNumber(request:{readonly message:string}):Promise<InputResult<number>>;
requestDigitText(request:{readonly message:string}):Promise<InputResult<string>>;
requestMultiplier(request:{readonly values:readonly number[]}):Promise<InputResult<readonly number[]>>;
cancelNumber():void;
cancelMultiplier():void;
};
readonly scope:{
readonly active:boolean;
/** Register cleanup; returned function unregisters it without executing it. */
defer(cleanup:()=>void):()=>void;
};
// Audio is absent until a real room-owned audio adapter is installed.
}
/** Presentation-only bridge. Cancellation callbacks are optional for legacy callers. */
export interface NumericToolsPort {
openNumberInput(request:{readonly message:string;readonly begin0?:boolean;readonly onConfirm:(value:number|string)=>void;readonly onCancel?:()=>void}):void;
closeNumberInput():void;
showMultiplier(request:{readonly values:readonly number[];readonly onConfirm:(values:readonly number[])=>void;readonly onCancel?:()=>void}):void;
hideMultiplier():void;
close():void;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "59d9a08e-2863-4aa6-93fa-75eae1905267",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,28 @@
import type {GameEntry} from './game-entry.ts';
import type {GameRoomProjection,GameRoomProjectionSource} from './game-room-projection.ts';
import type {PlatformGameSnapshot} from './snapshots.ts';
import type {RoomChatConfig} from './room-chat.ts';
export interface RoomPresentationActions {prepare():void;exit():void;}
export interface RoomPresentation {
render(snapshot:PlatformGameSnapshot,actions:RoomPresentationActions,projection?:GameRoomProjection|null):void;
close():void;
}
export interface GameBinding {
readonly entry:GameEntry;
mount(view:RoomPresentation):void;
readonly restoredSnapshot:unknown;
readonly roomProjection:GameRoomProjectionSource|null;
}
/** Application-owned registration. The platform consumes the selected definition only. */
export interface GameDefinition {
readonly config:Readonly<{versionResource:string;assetRoot:string}>;
readonly roomMenu:Readonly<{mainSceneButton:boolean;vipInfinite:boolean}>;
readonly key:string;
readonly route:string;
readonly createGame:(gameId:string|number)=>GameBinding;
readonly chat:RoomChatConfig;
readonly createPageClass:string;
readonly roomViewClass:string;
readonly resources:{readonly createRoom:string;readonly room:string;readonly roomScene:string};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "9c55d95a-dd90-4464-a021-65cb90c08155",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,15 +1,29 @@
import type { GameSeatMapper } from './game-seat-mapper.ts';
import type { PlatformGameSnapshot } from './snapshots.ts';
import type {RoomCapabilities} from './game-capabilities.ts';
export type GameHostCommand =
| {readonly type:'ui.open-number-input';readonly message:string;readonly begin0?:boolean;readonly onConfirm:(value:number|string)=>void}
| {readonly type:'ui.close-number-input'}
| {readonly type:'ui.show-multiplier';readonly values:readonly number[];readonly onConfirm:(values:readonly number[])=>void}
| {readonly type:'ui.hide-multiplier'}
| { readonly type:'room.apply-dissolution' }
| { readonly type: 'room.prepare' }
| { readonly type: 'room.exit' };
/** The only platform capability surface available to a game module. */
export interface GameHost {
/** Optional only for legacy test/host adapters; production hosts always provide it. */
readonly capabilities?:RoomCapabilities;
readonly seat: GameSeatMapper;
getSnapshot(): PlatformGameSnapshot;
subscribe(listener: (snapshot: PlatformGameSnapshot) => void): () => void;
sendGameMessage(rpc: string, data: unknown): void;
execute(command: GameHostCommand): void;
}
/** Explicit migration boundary; absence is an integration error, never a fallback. */
export function requireRoomCapabilities(host:GameHost):RoomCapabilities {
if(!host.capabilities)throw new Error('Game host must provide room capabilities');
return host.capabilities;
}
@@ -1,8 +1,11 @@
import type { GameHost } from './game-host.ts';
import type {GameRoomProjectionSource} from './game-room-projection.ts';
import type { GameServerMessage, PlatformToGameEvent } from './platform-events.ts';
/** Per-room game implementation with an explicit lifecycle. */
export interface GameModule {
/** Absent/null explicitly means this game has no public gameplay projection. */
readonly roomProjection?:GameRoomProjectionSource|null;
attach(host: GameHost): void;
handlePlatformEvent(event: PlatformToGameEvent): void;
handleGameMessage(message: GameServerMessage): void;
@@ -0,0 +1,13 @@
/** Owned by the game model. These facts never overwrite platform readiness or assets. */
export interface GameRoomFacts {
readonly roundPhase:string;
readonly selfParticipation:'playing'|'waiting'|'spectating';
readonly scoresBySeat:Readonly<Record<number,number>>;
/** A game may only add restrictions, never enable a platform-forbidden command. */
readonly restrictions:Readonly<Partial<Record<'prepare'|'start'|'exit',false>>>;
}
export interface GameRoomProjection extends GameRoomFacts {readonly revision:number;}
export interface GameRoomProjectionSource {
getSnapshot():GameRoomProjection;
subscribe(listener:(projection:GameRoomProjection)=>void):()=>void;
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "6a623110-c503-4e7e-9b26-692ec40256b7",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,9 +1,11 @@
export * from './game-entry.ts';
export * from './create-room-page.ts';
export * from './game-host.ts';
export * from './game-capabilities.ts';
export * from './game-room-projection.ts';
export * from './game-module.ts';
export * from './game-seat-mapper.ts';
export * from './platform-events.ts';
export * from './snapshots.ts';
export type {Roomtype} from './roomtype.ts';
export type {JsonValue,Roomtype} from './roomtype.ts';
@@ -1,7 +1,18 @@
export type RoomEntryInput =
| {readonly kind:'created';readonly sourceRpc:'create_room';readonly raw:Readonly<Record<string,unknown>>}
| {readonly kind:'login-waiting';readonly sourceRpc:'player_login';readonly raw:Readonly<Record<string,unknown>>}
| {readonly kind:'login-restored';readonly sourceRpc:'player_login';readonly raw:Readonly<Record<string,unknown>>;readonly deskinfo:unknown}
| {readonly kind:'joined-waiting'|'joined-started';readonly sourceRpc:'self_join_room';readonly raw:Readonly<Record<string,unknown>>}
| {readonly kind:'joined-snapshot';readonly sourceRpc:'self_join_room';readonly raw:Readonly<Record<string,unknown>>;readonly deskinfo:unknown};
export interface RoomStartMessage {readonly route:string;readonly rpc:string;readonly data:unknown;}
import type {Roomtype} from './roomtype.ts';
/** Platform lifecycle events delivered to the current game module. */
export type PlatformToGameEvent =
| { readonly type: 'room.entered'; readonly roomtype: Roomtype }
/** Explicit settlement integration point. The module must interpret deskfree;
* platform preserves the active session and does not invent a result screen. */
| {readonly type:'room.dissolution-confirmed';readonly freeNow:boolean;readonly deskfree?:Readonly<Record<string,unknown>>;readonly tips?:string;readonly time?:number}
| { readonly type: 'room.entered'; readonly roomtype: Roomtype;readonly entry?:RoomEntryInput }
| {readonly type:'room.started';readonly sourceRpc:'self_join_room'|'other_join_room'|'player_prepare'|'self_makewar'|'other_makewar';readonly message:RoomStartMessage}
| { readonly type: 'room.player-joined'; readonly seat: number }
| { readonly type: 'room.player-left'; readonly seat: number }
| { readonly type: 'room.player-ready'; readonly seat: number }
@@ -0,0 +1,10 @@
export interface RoomChatPhrase {readonly text:string;readonly audio:string;}
export interface RoomChatConfig {
readonly phrases:readonly RoomChatPhrase[];
/** Legacy maxLength counts JavaScript UTF-16 code units. */
readonly limitLength:number;
readonly showChatMs:number;
readonly announcementLimit:number;
readonly voiceHistoryTag:number;
readonly bannedWords:readonly string[];
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "c8f3ca02-72aa-44e5-a249-c9d88fe9b5cd",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -1,2 +1,3 @@
/** Opaque subgame wire configuration. The game defines its contents. */
export type Roomtype = string | readonly unknown[];
/** Opaque game-owned JSON transport value. */
export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {readonly [key:string]:JsonValue};
export type Roomtype = JsonValue;
@@ -0,0 +1,42 @@
import type {GameRoomFacts,GameRoomProjection,GameRoomProjectionSource} from './contracts/game-room-projection.ts';
function snapshot(facts:GameRoomFacts,revision:number):GameRoomProjection {
if(typeof facts.roundPhase!=='string'||facts.roundPhase.length===0)throw TypeError('Game round phase must be explicit');
if(!['playing','waiting','spectating'].includes(facts.selfParticipation))throw TypeError('Invalid game participation');
const scores:Record<number,number>={};
for(const [seat,score] of Object.entries(facts.scoresBySeat)){
if(!/^(0|[1-9][0-9]*)$/.test(seat)||!Number.isSafeInteger(Number(seat))||!Number.isFinite(score))throw TypeError('Game score requires a server seat and finite score');
scores[Number(seat)]=score;
}
const restrictions:Partial<Record<'prepare'|'start'|'exit',false>>={};
for(const [action,value] of Object.entries(facts.restrictions)){
if(!['prepare','start','exit'].includes(action)||value!==false)throw TypeError('Game restrictions may only disable known commands');
restrictions[action as keyof typeof restrictions]=false;
}
return Object.freeze({revision,roundPhase:facts.roundPhase,selfParticipation:facts.selfParticipation,
scoresBySeat:Object.freeze(scores),restrictions:Object.freeze(restrictions)});
}
/** The owner keeps commit/dispose; consumers receive only source. Each commit is a complete version. */
export function createGameRoomProjection(initial:GameRoomFacts):{
readonly source:GameRoomProjectionSource;commit(facts:GameRoomFacts):void;dispose():void;
}{
let current=snapshot(initial,0);let disposed=false;let notifying=false;
const listeners=new Set<(projection:GameRoomProjection)=>void>();
const queue:GameRoomProjection[]=[];
const active=()=>{if(disposed)throw Error('Game projection is disposed');};
const source:GameRoomProjectionSource=Object.freeze({getSnapshot:()=>{active();return current;},subscribe:(listener:(projection:GameRoomProjection)=>void)=>{
active();if(typeof listener!=='function')throw TypeError('Projection listener must be a function');listeners.add(listener);
try{listener(current);}catch(error){listeners.delete(listener);throw error;}
return ()=>{listeners.delete(listener);};
}});
return Object.freeze({source,commit:(facts:GameRoomFacts)=>{
active();const next=snapshot(facts,current.revision+1);current=next;queue.push(next);
if(notifying)return;notifying=true;const errors:unknown[]=[];
try{while(queue.length&&!disposed){const version=queue.shift()!;for(const listener of [...listeners]){
if(disposed)break;if(!listeners.has(listener))continue;
try{listener(version);}catch(error){errors.push(error);}
}}}finally{notifying=false;}
if(errors.length)throw Object.assign(new Error('Game projection notification failed'),{errors:Object.freeze(errors)});
},dispose:()=>{disposed=true;queue.length=0;listeners.clear();}});
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "5d4c6bdf-207c-4da3-9f77-6c80aa78dd76",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,6 @@
import type {GameRoomProjection} from './contracts/game-room-projection.ts';
/** Missing game projection means no additional restrictions, never extra permission. */
export function gameAllowsRoomAction(platformAllowed:boolean,game:GameRoomProjection|null,action:'prepare'|'start'|'exit'):boolean {
return platformAllowed&&(game===null||game.restrictions[action]!==false);
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "378da623-0982-4ab4-97ef-6ebbac2a1d06",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -0,0 +1,5 @@
import type {PlatformGameSnapshot} from './contracts/snapshots.ts';
/** GameUI's in-room exit/application action; unlimited rooms retain ordinary exit. */
export function roomExitAction(room:PlatformGameSnapshot['room']):{command:'room.exit'|'room.apply-dissolution';label:string}{
return room.stage===1&&room.infinite!==1?{command:'room.apply-dissolution',label:'申请解散'}:{command:'room.exit',label:'退出房间'};
}
@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2fb0e986-b14a-481d-baff-e88b670ff0c0",
"files": [],
"subMetas": {},
"userData": {}
}
@@ -77,7 +77,7 @@ export function isGameSessionOpenFault(value: unknown): value is GameSessionOpen
return extractGameSessionOpenFaultErrors(value) !== null;
}
/** Validates the compile-time game identity before it reaches the runtime. */
/** Validates declarations only; module contracts are checked on the real instance in open(). */
export function assertGameEntry(entry: GameEntry): void {
assertNonEmptyText(entry.key, 'key');
assertGameId(entry.gameId);
@@ -88,12 +88,6 @@ export function assertGameEntry(entry: GameEntry): void {
if (typeof entry.createModule !== 'function') {
throw new Error('GameEntry createModule must be a function');
}
const module = entry.createModule();
try {
assertGameModule(module);
} finally {
disposeModuleIfPresent(module);
}
}
/** The single GameEntry route admission check shared by runtime adapters. */
@@ -107,6 +101,10 @@ export function assertGameRoute(route: unknown): asserts route is string {
/** Owns one isolated game module for one room at a time. */
export class GameSessionHost {
private module: GameModule | undefined;
/** Null explicitly means the current game does not provide public gameplay facts. */
get roomProjection():import('../contracts/game-room-projection.ts').GameRoomProjectionSource|null {
return this.module?.roomProjection??null;
}
private currentState: GameSessionState = 'idle';
constructor(readonly entry: GameEntry) {
@@ -49,10 +49,10 @@
"height": 86,
"rawWidth": 137,
"rawHeight": 86,
"borderTop": 0,
"borderBottom": 0,
"borderLeft": 0,
"borderRight": 0,
"borderTop": 19,
"borderBottom": 17,
"borderLeft": 12,
"borderRight": 17,
"packable": true,
"pixelsToUnit": 100,
"pivotX": 0.5,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Some files were not shown because too many files have changed in this diff Show More