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