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,111 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {RoomCoordinator} from '../../../YouleNexus/assets/framework/platform/room/room-coordinator.ts';
import {RoomScope} from '../../../YouleNexus/assets/framework/platform/room/room-scope.ts';
function deferred<T>(){let resolve!:(value:T)=>void;let reject!:(reason:unknown)=>void;const promise=new Promise<T>((yes,no)=>{resolve=yes;reject=no;});return {promise,resolve,reject};}
class Clock {
callbacks=new Map<number,()=>void>();next=0;
setTimeout(callback:()=>void,_milliseconds:number):unknown{const id=++this.next;this.callbacks.set(id,callback);return id;}
clearTimeout(handle:unknown):void{this.callbacks.delete(handle as number);}
fire():void{const callbacks=[...this.callbacks.values()];this.callbacks.clear();callbacks.forEach(callback=>callback());}
}
function fixture(capacity=3){const clock=new Clock(),failures:unknown[]=[];const coordinator=new RoomCoordinator<()=>void>({capacity,maxWaitMs:500,clock,onFailure:failure=>failures.push(failure)});return {clock,failures,coordinator};}
const settle=async()=>{await Promise.resolve();await Promise.resolve();};
test('scope invalidates before reverse cleanup and attempts every disposer once',()=>{
const order:string[]=[],first=Error('first'),second=Error('second');
const scope=new RoomScope(1,()=>{order.push('invalidate');throw first;});
scope.own(()=>order.push('a'));scope.own(()=>{order.push('b');throw second;});
assert.deepEqual(scope.dispose(),[first,second]);assert.equal(scope.active,false);
assert.deepEqual(order,['invalidate','b','a']);assert.deepEqual(scope.dispose(),[]);
scope.own(()=>order.push('late'));assert.equal(order[order.length-1],'late');assert.equal(scope.resourceCount,0);
});
test('mount gates ready and FIFO; reentrant message stays after already queued inputs',async()=>{
const {coordinator,clock,failures}=fixture(),mount=deferred<void>(),events:string[]=[];
coordinator.enter({invalidate(){},mount:()=>mount.promise,ready:()=>events.push('ready'),handle:message=>message()});
coordinator.dispatch(()=>{events.push('one');coordinator.dispatch(()=>events.push('three'));});
coordinator.dispatch(()=>events.push('two'));
assert.deepEqual(events,[]);assert.equal(coordinator.state,'entering');assert.equal(clock.callbacks.size,1);
mount.resolve();await settle();
assert.deepEqual(events,['ready','one','two','three']);assert.equal(coordinator.state,'active');assert.equal(clock.callbacks.size,0);assert.deepEqual(failures,[]);
});
test('cancel/kick invalidates immediately and late mount only releases its own resource',async()=>{
const {coordinator,clock,failures}=fixture(),oldMount=deferred<()=>void>(),events:string[]=[];
const old=coordinator.enter({invalidate:()=>events.push('old-invalid'),mount:()=>oldMount.promise,ready:()=>events.push('old-ready'),handle:message=>message()});
coordinator.dispatch(()=>events.push('old-message'));coordinator.cancel();
const current=coordinator.enter({invalidate:()=>events.push('new-invalid'),mount:()=>()=>events.push('new-clean'),ready:()=>events.push('new-ready'),handle:message=>message()});
oldMount.resolve(()=>events.push('old-clean'));await settle();
assert.deepEqual(events,['old-invalid','new-ready','old-clean']);assert.equal(old.active,false);assert.equal(current.active,true);assert.equal(clock.callbacks.size,0);assert.deepEqual(failures,[]);
});
test('overflow and queue timeout fail the session with queue and timer fully released',()=>{
for(const cause of ['overflow','timeout']){
const {coordinator,clock,failures}=fixture(1),mount=deferred<void>();let invalidations=0;
const scope=coordinator.enter({invalidate:()=>invalidations++,mount:()=>mount.promise,ready(){},handle:message=>message()});
coordinator.dispatch(()=>{});
if(cause==='overflow')coordinator.dispatch(()=>{});else clock.fire();
assert.equal(coordinator.state,'failed');assert.equal(scope.active,false);assert.equal(invalidations,1);assert.equal(clock.callbacks.size,0);assert.equal(failures.length,1);
assert.match(String(coordinator.failure?.errors[0]),cause==='overflow'?/capacity/:/timed out/);
}
});
test('mount and handler failures aggregate cleanup errors and report once',async()=>{
for(const stage of ['mount','ready','handle']){
const {coordinator,failures}=fixture(),failure=Error(stage),cleanup=Error('cleanup'),mount=deferred<void>();
coordinator.enter({invalidate:()=>{throw cleanup;},mount:()=>mount.promise,ready:()=>{if(stage==='ready')throw failure;},handle:()=>{throw failure;}});
if(stage==='mount')mount.reject(failure);else {coordinator.dispatch(()=>{});mount.resolve();}
await settle();
assert.equal(coordinator.state,'failed');assert.deepEqual(coordinator.failure?.errors,[failure,cleanup]);assert.equal(failures.length,1);
}
});
test('a stale mount cleanup failure reports its generation without failing the new room',async()=>{
const {coordinator,failures}=fixture(),mount=deferred<()=>void>(),cleanup=Error('late cleanup');
const old=coordinator.enter({invalidate(){},mount:()=>mount.promise,ready(){},handle(){}});
coordinator.enter({invalidate(){},mount(){},ready(){},handle(){}});
mount.resolve(()=>{throw cleanup;});await settle();
assert.equal(coordinator.state,'active');assert.equal(coordinator.failure,null);assert.equal(failures.length,1);
assert.equal((failures[0] as {generation:number}).generation,old.generation);
});
test('one hundred synchronous enter/exit cycles return scope resources and timers to baseline',()=>{
const {coordinator,clock,failures}=fixture();let resources=0,leases=0;
for(let cycle=0;cycle<100;cycle++){
leases++;
const scope=coordinator.enter({invalidate:()=>{leases--;},mount:scope=>{resources++;scope.own(()=>{resources--;});},ready(){},handle:message=>message()});
coordinator.dispatch(()=>{});assert.equal(coordinator.state,'active');assert.equal(scope.generation,cycle+1);
assert.deepEqual(coordinator.cancel(),[]);assert.equal(scope.resourceCount,0);assert.equal(resources,0);assert.equal(leases,0);assert.equal(clock.callbacks.size,0);
}
assert.deepEqual(failures,[]);
});
test('invalid mailbox configuration fails at its source',()=>{
for(const capacity of [0,-1,1.5,NaN])assert.throws(()=>fixture(capacity),/capacity/);
});
test('cancelling inside ready prevents queued input from reaching a revoked scope',async()=>{
const {coordinator,failures,clock}=fixture(),mount=deferred<void>();let delivered=0;
coordinator.enter({invalidate(){},mount:()=>mount.promise,ready:()=>{coordinator.cancel();},handle:()=>delivered++});
coordinator.dispatch(()=>{});mount.resolve();await settle();
assert.equal(delivered,0);assert.equal(coordinator.state,'outside');assert.equal(clock.callbacks.size,0);assert.deepEqual(failures,[]);
});
test('explicit cancellation returns every cleanup failure and remains idempotent',()=>{
const {coordinator}=fixture(),invalid=Error('lease'),ui=Error('view');const order:string[]=[];
coordinator.enter({invalidate:()=>{order.push('lease');throw invalid;},mount:scope=>{scope.own(()=>order.push('subscription'));return ()=>{order.push('view');throw ui;};},ready(){},handle(){}});
assert.deepEqual(coordinator.cancel(),[invalid,ui]);assert.deepEqual(order,['lease','view','subscription']);assert.deepEqual(coordinator.cancel(),[]);
});
test('stale rejected load does not fail or notify the new active room',async()=>{
const {coordinator,failures}=fixture(),mount=deferred<void>();
coordinator.enter({invalidate(){},mount:()=>mount.promise,ready(){},handle(){}});
coordinator.enter({invalidate(){},mount(){},ready(){},handle(){}});
mount.reject(Error('superseded load'));await settle();assert.equal(coordinator.state,'active');assert.deepEqual(failures,[]);
});
test('invalid queue deadlines fail before a room scope can be entered',()=>{
for(const maxWaitMs of [0,-1,Infinity,NaN])assert.throws(()=>new RoomCoordinator({capacity:1,maxWaitMs,clock:new Clock(),onFailure(){}}),/maxWaitMs/);
});
test('a failing diagnostic callback is retained without poisoning a newer room',async()=>{
const clock=new Clock(),mount=deferred<()=>void>(),cleanup=Error('old cleanup'),diagnostic=Error('reporter');
const coordinator=new RoomCoordinator<void>({capacity:1,maxWaitMs:500,clock,onFailure(){throw diagnostic;}});
const old=coordinator.enter({invalidate(){},mount:()=>mount.promise,ready(){},handle(){}});
coordinator.enter({invalidate(){},mount(){},ready(){},handle(){}});
mount.resolve(()=>{throw cleanup;});await settle();
assert.equal(coordinator.state,'active');assert.equal(coordinator.failure,null);
assert.equal(coordinator.diagnosticFailure?.generation,old.generation);assert.deepEqual(coordinator.diagnosticFailure?.errors,[cleanup,diagnostic]);
});
@@ -0,0 +1,38 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {SceneOrderedGameSessionHost} from '../../../YouleNexus/assets/framework/platform/room/scene-game-session.ts';
import type {GameEntry,GameHost,GameModule} from '../../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import type {ScenePort} from '../../../YouleNexus/assets/framework/platform/scene-port.ts';
function thrown(action:()=>void):unknown{let didThrow=false,value:unknown;try{action();}catch(error){didThrow=true;value=error;}assert.equal(didThrow,true);return value;}
function setup(showRoom:ScenePort['showRoom'],dispose:()=>void=()=>{}){
const failures:unknown[]=[],lease={} as GameHost;
const entry:GameEntry={key:'fixture',route:'fixture',gameId:1,resolveSeatCount:()=>4,createModule(){
let attached=false;
const module:GameModule={attach(){attached=true;},handlePlatformEvent(){},handleGameMessage(){},restore(){},dispose(){if(attached)dispose();}};
return module;
}};
const scene:ScenePort={showRoom,showLoading(){},showLogin(){},showLobby(){},showReconnect(){},showKicked(){},showServerDenial(){},showFatal(){}};
const host=new SceneOrderedGameSessionHost(entry,scene,{capacity:3,maxWaitMs:500,clock:{setTimeout(){return 1;},clearTimeout(){}},onFailure:failure=>failures.push(failure)},value=>assert.equal(value,lease));
return {host,lease,failures};
}
for(const cause of [Error('mount after stop'),undefined]){
test(`sync mount stop then throw ${String(cause)} retains exact original identity`,()=>{
const scenario=setup(()=>{scenario.host.dispose();throw cause;});
assert.equal(thrown(()=>scenario.host.open(scenario.lease)),cause);assert.equal(scenario.host.state,'disposed');assert.deepEqual(scenario.failures,[]);
});
test(`single module cleanup throw ${String(cause)} retains exact original identity`,()=>{
const scenario=setup(()=>{},()=>{throw cause;});scenario.host.open(scenario.lease);
assert.equal(thrown(()=>scenario.host.dispose()),cause);assert.equal(scenario.host.state,'disposed');scenario.host.dispose();
});
}
test('scope and module cleanup errors aggregate in execution order and both complete',()=>{
const first=Error('view'),second=Error('module');let moduleReleased=false;
const scenario=setup(scope=>{scope.own(()=>{throw first;});},()=>{moduleReleased=true;throw second;});
scenario.host.open(scenario.lease);
const failure=thrown(()=>scenario.host.dispose()) as {errors:unknown[]};assert.deepEqual(failure.errors,[first,second]);assert.equal(moduleReleased,true);
});
test('sync mount failure returns to the wire caller instead of the async diagnostic callback',()=>{
const cause=Error('synchronous load'),scenario=setup(()=>{throw cause;});
assert.equal(thrown(()=>scenario.host.open(scenario.lease)),cause);assert.deepEqual(scenario.failures,[]);scenario.host.dispose();
});