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
@@ -71,7 +71,7 @@ function entry(route = 'fixture-route'): GameEntry {
key: 'fixture',
gameId: 'game-41',
route,
resolveSeatCount: (roomtype) => roomtype[0] as number,
resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;},
createModule: () => ({
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
}),
@@ -81,6 +81,8 @@ function entry(route = 'fixture-route'): GameEntry {
function makeLease(options: {
readonly store?: PlatformStore;
readonly gameEntry?: GameEntry;
readonly feedback?:import('../../YouleNexus/assets/framework/sdk/contracts/game-capabilities.ts').FeedbackOwnerPort;
readonly numericTools?: import('../../YouleNexus/assets/framework/platform/game-host-adapter.ts').NumericToolsPort;
} = {}) {
const store = options.store ?? roomStore();
const sent: OutboundEnvelope[] = [];
@@ -91,10 +93,39 @@ function makeLease(options: {
store,
commands,
send,
numericTools: options.numericTools,
feedback:options.feedback,
});
return { ...lease, store, sent };
}
test('numeric UI capabilities preserve values and revoke callbacks when their room lease ends',()=>{
let input:((value:number|string)=>void)|undefined;
let calculator:((values:readonly number[])=>void)|undefined;
const results:unknown[]=[];let closes=0;let hides=0;
const tools={openNumberInput(request){assert.equal(request.message,'ID');input=request.onConfirm;},
showMultiplier(request){assert.deepEqual(request.values,[0.1,-2]);calculator=request.onConfirm;},
hideMultiplier(){hides++;},closeNumberInput(){},close(){closes++;}} satisfies import('../../YouleNexus/assets/framework/platform/game-host-adapter.ts').NumericToolsPort;
const lease=makeLease({numericTools:tools});
lease.host.execute({type:'ui.open-number-input',message:'ID',begin0:true,onConfirm:value=>results.push(value)});
lease.host.execute({type:'ui.show-multiplier',values:[0.1,-2],onConfirm:value=>results.push(value)});
input!('001');calculator!([0.02,-0.4]);assert.deepEqual(results,['001',[0.02,-0.4]]);
lease.host.execute({type:'ui.hide-multiplier'});assert.equal(hides,1);
lease.invalidate();lease.invalidate();assert.equal(closes,1);
input!('old');calculator!([9]);assert.equal(results.length,2);
assert.throws(()=>lease.host.execute({type:'ui.hide-multiplier'}),GameSessionDisposedError);
});
test('numeric UI replacement and explicit close discard old callbacks within the same room',()=>{
const callbacks:((value:number|string)=>void)[]=[];let closes=0;const results:unknown[]=[];
const lease=makeLease({numericTools:{openNumberInput:request=>{callbacks.push(request.onConfirm);},
closeNumberInput:()=>{closes++;},showMultiplier:()=>{},hideMultiplier:()=>{},close:()=>{}}});
for(let index=0;index<2;index++)lease.host.execute({type:'ui.open-number-input',message:'ID',onConfirm:value=>results.push(value)});
callbacks[0]('stale');callbacks[1]('current');assert.deepEqual(results,['current']);
lease.host.execute({type:'ui.close-number-input'});callbacks[1]('closed');
assert.equal(closes,1);assert.deepEqual(results,['current']);
});
test('sendGameMessage binds app and GameEntry route, rejects empty rpc, and preserves data identity', () => {
const { host, sent } = makeLease();
const data = { card: 7 };
@@ -188,6 +219,7 @@ test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roo
assert.equal(Object.isFrozen(snapshot.self), true);
assert.equal(Object.isFrozen(snapshot.room), true);
assert.equal(Object.isFrozen(snapshot.room.roomtype), true);
assert.ok(Array.isArray(snapshot.room.roomtype));
assert.equal(Object.isFrozen(snapshot.room.roomtype[1]), true);
assert.equal(Object.isFrozen((snapshot.room.roomtype[1] as { nested: unknown[] }).nested), true);
assert.equal(Object.isFrozen(snapshot.seats), true);
@@ -206,7 +238,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release
const staleDisposeResults: unknown[] = [];
const gameEntry: GameEntry = {
...entry(),
resolveSeatCount: (roomtype) => roomtype[0] as number,
resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;},
createModule: () => {
let host: GameHost | null = null;
return {
@@ -505,3 +537,61 @@ test('invalidate is idempotent, old unsubscribe stays safe, and every stale Host
assert.equal(callbacks, 1);
assert.equal(sent.length, 0);
});
test('game host exposes dissolution application through exact room command',()=>{
const {host,sent}=makeLease({store:roomStore(4,1,1)});host.execute({type:'room.apply-dissolution'});
assert.deepEqual(sent,[{app:'youle',route:'room',rpc:'self_apply_free_room',data:{agentid:17,gameid:'game-41',playerid:430511,roomcode:'100001'}}]);
});
test('grouped input results distinguish types, cancellation, overlap and lease cleanup',async()=>{
let input!:Parameters<import('../../YouleNexus/assets/framework/sdk/contracts/game-capabilities.ts').NumericToolsPort['openNumberInput']>[0];
let multiplier!:Parameters<import('../../YouleNexus/assets/framework/sdk/contracts/game-capabilities.ts').NumericToolsPort['showMultiplier']>[0];
const tools={openNumberInput(request:typeof input){input=request;},showMultiplier(request:typeof multiplier){multiplier=request;},closeNumberInput(){input?.onCancel?.();},hideMultiplier(){multiplier?.onCancel?.();},close(){throw Error('close failed');}};
const lease=makeLease({numericTools:tools});const caps=lease.host.capabilities!;
const number=caps.ui.requestNumber({message:'N'});await assert.rejects(caps.ui.requestDigitText({message:'overlap'}),/pending/);
input.onConfirm(12);assert.deepEqual(await number,{kind:'confirmed',value:12});
const text=caps.ui.requestDigitText({message:'ID'});input.onConfirm('001');assert.deepEqual(await text,{kind:'confirmed',value:'001'});
const cancelled=caps.ui.requestNumber({message:'Cancel'});input.onCancel!();assert.deepEqual(await cancelled,{kind:'cancelled',reason:'user'});
const replaced=caps.ui.requestNumber({message:'Replace'});const late=input.onConfirm;
lease.host.execute({type:'ui.open-number-input',message:'Legacy',onConfirm(){}});assert.deepEqual(await replaced,{kind:'cancelled',reason:'replaced'});late(99);
const pending=caps.ui.requestMultiplier({values:[1,2]});const cleanup:string[]=[];
caps.scope.defer(()=>{cleanup.push('first');});caps.scope.defer(()=>{cleanup.push('second');throw Error('cleanup failed');});
assert.throws(()=>lease.invalidate());assert.deepEqual(cleanup,['second','first']);assert.equal(caps.scope.active,false);
assert.deepEqual(await pending,{kind:'cancelled',reason:'scope-ended'});multiplier.onConfirm([9]);
assert.throws(()=>caps.messages.send('play',{}),GameSessionDisposedError);assert.throws(()=>caps.scope.defer(()=>{}),GameSessionDisposedError);
await assert.rejects(caps.ui.requestNumber({message:'stale'}),GameSessionDisposedError);lease.invalidate();
});
test('typed inputs reject invalid adapter results without leaving a request pending',async()=>{
let confirm!:(value:number|string)=>void;const lease=makeLease({numericTools:{openNumberInput(r){confirm=r.onConfirm;},closeNumberInput(){},showMultiplier(){},hideMultiplier(){},close(){}}});
const first=lease.host.capabilities!.ui.requestNumber({message:'N'});confirm('wrong');await assert.rejects(first,/type/);
const next=lease.host.capabilities!.ui.requestNumber({message:'N'});confirm(3);assert.deepEqual(await next,{kind:'confirmed',value:3});lease.invalidate();
});
test('grouped commands bind routing and scope while explicit exit uses central policy',()=>{
const {host,sent}=makeLease({store:roomStore(4,1,1)});const caps=host.capabilities!;
assert.equal(caps.scope.active,true);caps.room.requestExit();assert.equal(sent[0].rpc,'self_apply_free_room');
caps.messages.send('card',{value:1});assert.equal(sent[1].route,'fixture-route');
assert.throws(()=>caps.messages.send('card',{value:undefined} as any),/JSON/);
});
test('pending input closes its partial view and preserves both opening and cleanup failures',async()=>{
const openError=Error('open failed'),closeError=Error('close failed');let closes=0;
const lease=makeLease({numericTools:{openNumberInput(){throw openError;},closeNumberInput(){closes++;throw closeError;},showMultiplier(){},hideMultiplier(){},close(){}}});
await assert.rejects(lease.host.capabilities!.ui.requestNumber({message:'N'}),error=>{
assert.deepEqual((error as Error&{errors:unknown[]}).errors,[openError,closeError]);return true;
});
assert.equal(closes,1);lease.invalidate();
});
test('late grouped input confirmation after room replacement resolves scope cancellation',async()=>{
let confirm!:(value:number|string)=>void;const lease=makeLease({numericTools:{openNumberInput(r){confirm=r.onConfirm;},closeNumberInput(){},showMultiplier(){},hideMultiplier(){},close(){}}});
const result=lease.host.capabilities!.ui.requestNumber({message:'N'});
lease.store.clearRoom();confirm(2);assert.deepEqual(await result,{kind:'cancelled',reason:'scope-ended'});lease.invalidate();
});
test('owned UI handles are automatically released and cannot close another source',()=>{
const closed:string[]=[];let count=0;
const lease=makeLease({feedback:{beginLoading(){const id='load'+(++count);return {close(){closed.push(id);}};},showBusinessTip(){return {close(){closed.push('tip');}};}}});
const a=lease.host.capabilities!.ui.beginLoading(),b=lease.host.capabilities!.ui.beginLoading();
lease.host.capabilities!.ui.showBusinessTip('操作成功',1000);a.close();a.close();assert.deepEqual(closed,['load1']);
lease.invalidate();assert.deepEqual(closed,['load1','tip','load2']);b.close();assert.equal(closed.length,3);
assert.throws(()=>lease.host.capabilities!.ui.beginLoading(),GameSessionDisposedError);
});