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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user