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
@@ -13,8 +13,8 @@ import {
GameSessionHost,
GameSessionOpenFault,
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
import {OwnedRoomHistory} from '../../YouleNexus/assets/framework/adapters/native/owned-room-history.ts';
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
import {OwnedRoomHistory} from '../../YouleNexus/assets/framework/adapters/native/owned-room-history.ts';
import { RuntimeSession, RuntimeSessionFault } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { selectGameSnapshot } from '../../YouleNexus/assets/framework/platform/stores/selectors.ts';
@@ -22,6 +22,14 @@ function fixture(name: string): Record<string, unknown> {
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')).data;
}
test('session fault message exposes the original cause and preserves error references',()=>{
const cause=new RangeError('GameEntry seat count 3 does not match room seat count 4');
const fault=new RuntimeSessionFault('login room open',[cause]);
assert.match(fault.message,/login room open.*RangeError: GameEntry seat count 3 does not match room seat count 4/);
assert.equal(fault.errors[0],cause);
assert.ok(Object.isFrozen(fault.errors));
});
function makeSession(roomHistory?:OwnedRoomHistory) {
const store = new PlatformStore();
const log: string[] = [];
@@ -54,7 +62,7 @@ function makeSession(roomHistory?:OwnedRoomHistory) {
};
const gameSession = new GameSessionHost(entry);
log.length = 0;
const session = new RuntimeSession({
const session = new RuntimeSession({
roomHistory,
store,
gameSession,
@@ -134,7 +142,7 @@ test('RuntimeSession self join commits, opens a fresh session, and restores the
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-success.json'));
setup.log.length = 0;
const join = fixture('self-join-room.json');
const join:Record<string,unknown> = {...fixture('self-join-room.json'),deskwar:0};
setup.store.subscribe(() => { setup.log.push('store'); });
setup.session.handleSelfJoin(join);
@@ -173,8 +181,8 @@ test('RuntimeSession self join honors the parser-owned deskwar and deskinfo matr
if (state.room.kind === 'inside') {
assert.equal(state.room.stage, row.expectedStage, JSON.stringify(row));
}
assert.equal(setup.restored.length, row.deskinfo ? 1 : 0, JSON.stringify(row));
if (row.deskinfo) assert.equal(setup.restored[0], deskinfo);
assert.equal(setup.restored.length, row.deskinfo && row.deskwar !== 1 ? 1 : 0, JSON.stringify(row));
if (row.deskinfo && row.deskwar !== 1) assert.equal(setup.restored[0], deskinfo);
}
});
@@ -200,7 +208,6 @@ function makeLeaseSession() {
const errors = Object.fromEntries([
'lease-factory', 'module-create', 'attach', 'event', 'restore', 'dispose', 'invalidate',
].map((point) => [point, new Error(`failure:${point}`)])) as Record<LeaseFailurePoint, Error>;
let validatingEntry = true;
let moduleDisposals = 0;
function host(): GameHost {
@@ -219,21 +226,20 @@ function makeLeaseSession() {
route: 'fixture-game',
resolveSeatCount: () => 4,
createModule: (): GameModule => {
if (!validatingEntry && failures.has('module-create')) throw errors['module-create'];
if (failures.has('module-create')) throw errors['module-create'];
return {
attach(attachedHost) {
if (!validatingEntry) attachedHosts.push(attachedHost);
if (!validatingEntry && failures.has('attach')) throw errors.attach;
attachedHosts.push(attachedHost);
if (failures.has('attach')) throw errors.attach;
},
handlePlatformEvent() {
if (!validatingEntry && failures.has('event')) throw errors.event;
if (failures.has('event')) throw errors.event;
},
handleGameMessage() {},
restore() {
if (!validatingEntry && failures.has('restore')) throw errors.restore;
if (failures.has('restore')) throw errors.restore;
},
dispose() {
if (validatingEntry) return;
moduleDisposals += 1;
if (failures.has('dispose')) throw errors.dispose;
},
@@ -241,7 +247,6 @@ function makeLeaseSession() {
},
};
const gameSession = new GameSessionHost(entry);
validatingEntry = false;
const sessionOptions = {
store,
gameSession,
@@ -544,72 +549,90 @@ test('RuntimeSession keeps ordinary aggregates and unbranded or accessor spoofs
assert.equal(accessorReads, 0);
assert.equal(descriptorReads, 2);
});
test('server room outcomes update owned room cache at the session boundary',()=>{
const values=new Map<string,string>();const history=new OwnedRoomHistory({getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},'g','a');
const setup=makeSession(history);setup.session.handleLogin({...fixture('player-login-room.json'),infinite:0});
const room=setup.store.getState().room;if(room.kind!=='inside')throw Error('fixture room');
setup.session.handleSelfExit({seat:room.selfSeat,raw:{isowner:1,roomcode:room.roomcode}});
assert.deepEqual(history.read(),[Number(room.roomcode)]);
setup.session.handleSelfJoin({state:99,roomcode:room.roomcode});assert.deepEqual(history.read(),[]);
});
test('create success uses login profile and entry seats without wire players, opens once and exits', () => {
const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json'));
const data={state:0,roomcode:'789123',seat:2,roomtype:[1,4],makewar:0,asetcount:8};
setup.store.subscribe(()=>setup.log.push('store'));
setup.session.handleCreateRoom(data);
const state=setup.store.getState();
assert.equal(state.room.kind,'inside');
if(state.room.kind!=='inside')throw Error('missing room');
assert.deepEqual(state.room.seatPlayerIds,[null,null,430511,null]);
assert.equal(state.room.selfSeat,2);assert.equal(state.room.stage,0);
assert.equal(state.room.needprepare,0);assert.equal(state.room.infinite,0);
assert.deepEqual(state.room.readySeats,[]);assert.deepEqual(state.room.offlineSeats,[]);
assert.equal(state.players.entities[430511].isprepare,0);
assert.equal('players' in data,false);
assert.deepEqual(setup.log,['store','attach','event:room.entered']);
setup.session.handleCreateRoom({...data});
assert.equal(setup.store.getState(),state);
assert.deepEqual(setup.log,['store','attach','event:room.entered']);
setup.session.handleSelfExit({raw:{}});
assert.equal(setup.store.getState().room.kind,'outside');
assert.equal(setup.gameSession.state,'idle');assert.ok(setup.log.includes('dispose'));
});
test('create denial never commits or instantiates a game; invalid seat fails before state mutation',()=>{
const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json'));
const before=setup.store.getState(), denial={state:2,showerror:1,error:'denied'};
setup.session.handleCreateRoom(denial);
assert.equal(setup.store.getState(),before);assert.deepEqual(setup.log,[]);
assert.deepEqual(setup.denials,[{rpc:'create_room',state:2,data:denial}]);
assert.throws(()=>setup.session.handleCreateRoom({state:0,roomcode:'123',seat:4,roomtype:[1]}),/seat/);
assert.equal(setup.store.getState(),before);
test('server room outcomes update owned room cache at the session boundary',()=>{
const values=new Map<string,string>();const history=new OwnedRoomHistory({getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},'g','a');
const setup=makeSession(history);setup.session.handleLogin({...fixture('player-login-room.json'),infinite:0});
const room=setup.store.getState().room;if(room.kind!=='inside')throw Error('fixture room');
setup.session.handleSelfExit({seat:room.selfSeat,raw:{isowner:1,roomcode:room.roomcode}});
assert.deepEqual(history.read(),[Number(room.roomcode)]);
setup.session.handleSelfJoin({state:99,roomcode:room.roomcode});assert.deepEqual(history.read(),[]);
});
test('duplicate successful self join responses do not replace module or restore twice',()=>{
const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json'));
const data=fixture('self-join-room.json');setup.session.handleSelfJoin(data);
const before=setup.store.getState(),log=[...setup.log];
setup.session.handleSelfJoin({...data});
assert.equal(setup.store.getState(),before);assert.deepEqual(setup.log,log);
assert.equal(setup.restored.length,1);
test('create success uses login profile and entry seats without wire players, opens once and exits', () => {
const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json'));
const data={state:0,roomcode:'789123',seat:2,roomtype:[1,4],makewar:0,asetcount:8};
setup.store.subscribe(()=>setup.log.push('store'));
setup.session.handleCreateRoom(data);
const state=setup.store.getState();
assert.equal(state.room.kind,'inside');
if(state.room.kind!=='inside')throw Error('missing room');
assert.deepEqual(state.room.seatPlayerIds,[null,null,430511,null]);
assert.equal(state.room.selfSeat,2);assert.equal(state.room.stage,0);
assert.equal(state.room.needprepare,0);assert.equal(state.room.infinite,0);
assert.deepEqual(state.room.readySeats,[]);assert.deepEqual(state.room.offlineSeats,[]);
assert.equal(state.players.entities[430511].isprepare,0);
assert.equal('players' in data,false);
assert.deepEqual(setup.log,['store','attach','event:room.entered']);
setup.session.handleCreateRoom({...data});
assert.equal(setup.store.getState(),state);
assert.deepEqual(setup.log,['store','attach','event:room.entered']);
setup.session.handleSelfExit({raw:{}});
assert.equal(setup.store.getState().room.kind,'outside');
assert.equal(setup.gameSession.state,'idle');assert.ok(setup.log.includes('dispose'));
});
for (const mutate of [
(roomtype: readonly unknown[]) => { (roomtype as unknown[])[0] = 999; },
(roomtype: readonly unknown[]) => { (roomtype[1] as {count: number}).count = 999; },
]) test('create seat resolver cannot mutate raw roomtype or partially commit a room', () => {
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-success.json'));
const before = setup.store.getState();
const data = {state: 0, roomcode: '123456', seat: 0, roomtype: [1, {count: 4}]};
setup.gameSession.entry.resolveSeatCount = roomtype => { assert.ok(Array.isArray(roomtype)); mutate(roomtype); return 4; };
assert.throws(() => setup.session.handleCreateRoom(data), TypeError);
assert.deepEqual(data.roomtype, [1, {count: 4}]);
assert.equal(Object.isFrozen(data.roomtype), false);
assert.equal(Object.isFrozen(data.roomtype[1]), false);
assert.equal(setup.store.getState(), before);
assert.equal(setup.gameSession.state, 'idle');
assert.deepEqual(setup.log, []);
test('create denial never commits or instantiates a game; invalid seat fails before state mutation',()=>{
const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json'));
const before=setup.store.getState(), denial={state:2,showerror:1,error:'denied'};
setup.session.handleCreateRoom(denial);
assert.equal(setup.store.getState(),before);assert.deepEqual(setup.log,[]);
assert.deepEqual(setup.denials,[{rpc:'create_room',state:2,data:denial}]);
assert.throws(()=>setup.session.handleCreateRoom({state:0,roomcode:'123',seat:4,roomtype:[1]}),/seat/);
assert.equal(setup.store.getState(),before);
});
test('duplicate successful self join responses do not replace module or restore twice',()=>{
const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json'));
const data={...fixture('self-join-room.json'),deskwar:0};setup.session.handleSelfJoin(data);
const before=setup.store.getState(),log=[...setup.log];
setup.session.handleSelfJoin({...data});
assert.equal(setup.store.getState(),before);assert.deepEqual(setup.log,log);
assert.equal(setup.restored.length,1);
});
for (const mutate of [
(roomtype: readonly unknown[]) => { (roomtype as unknown[])[0] = 999; },
(roomtype: readonly unknown[]) => { (roomtype[1] as {count: number}).count = 999; },
]) test('create seat resolver cannot mutate raw roomtype or partially commit a room', () => {
const setup = makeSession();
setup.session.handleLogin(fixture('player-login-success.json'));
const before = setup.store.getState();
const data = {state: 0, roomcode: '123456', seat: 0, roomtype: [1, {count: 4}]};
setup.gameSession.entry.resolveSeatCount = roomtype => { assert.ok(Array.isArray(roomtype)); mutate(roomtype); return 4; };
assert.throws(() => setup.session.handleCreateRoom(data), TypeError);
assert.deepEqual(data.roomtype, [1, {count: 4}]);
assert.equal(Object.isFrozen(data.roomtype), false);
assert.equal(Object.isFrozen(data.roomtype[1]), false);
assert.equal(setup.store.getState(), before);
assert.equal(setup.gameSession.state, 'idle');
assert.deepEqual(setup.log, []);
});
test('entry inputs preserve create/login/join sources and deskwar takes priority over deskinfo',()=>{
for(const kind of ['login-waiting','login-restored','joined-waiting','joined-snapshot','joined-started'] as const){
const setup=makeSession();
const login=kind.startsWith('login');const raw=fixture(login?'player-login-room.json':'self-join-room.json');
const snapshot={unknownField:[1,'x']};
if(kind==='login-restored'||kind==='joined-snapshot'||kind==='joined-started')raw.deskinfo=snapshot;else delete raw.deskinfo;
raw.deskwar=kind==='joined-started'?1:0;
if(login)setup.session.handleLogin(raw);else{setup.session.handleLogin(fixture('player-login-success.json'));setup.session.handleSelfJoin(raw);}
const event=setup.events[0].event;assert.equal(event.type,'room.entered');if(event.type!=='room.entered')throw Error('entry');
assert.equal(event.entry?.kind,kind);assert.equal(event.entry?.raw,raw);
assert.equal(setup.restored.length,kind==='login-restored'||kind==='joined-snapshot'?1:0);
assert.equal(setup.events.filter(x=>x.event.type==='room.started').length,kind==='joined-started'?1:0);
}
const setup=makeSession();setup.session.handleLogin(fixture('player-login-success.json'));
const raw={state:0,roomcode:'123456',seat:0,roomtype:{mode:[true,null,3]},infinite:0};setup.session.handleCreateRoom(raw);
const event=setup.events[0].event;if(event.type!=='room.entered')throw Error('entry');assert.equal(event.entry?.kind,'created');assert.equal(event.entry?.raw,raw);
});