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
@@ -82,14 +82,15 @@ test('assertGameEntry rejects invalid identity, routes, resolver presence, and m
assert.throws(() => assertGameEntry({ ...entry, route }), /reserved.*route/);
}
assert.throws(() => assertGameEntry({ ...entry, resolveSeatCount: undefined as never }), /resolveSeatCount/);
assert.throws(() => assertGameEntry({ ...entry, createModule: () => undefined as never }), /module/);
assert.throws(() => assertGameEntry({ ...entry, createModule: undefined as never }), /createModule/);
});
test('GameEntry preflight does not call a resolver before its game-owned roomtype exists', () => {
const calls: Array<readonly unknown[]> = [];
const entry = {
...makeEntry(() => makeModule([])),
resolveSeatCount(roomtype: readonly unknown[]) {
resolveSeatCount(roomtype: import('../../YouleNexus/assets/framework/sdk/contracts/roomtype.ts').Roomtype) {
assert.ok(Array.isArray(roomtype));
calls.push(roomtype);
if (roomtype[1] !== 4) throw new Error('Game roomtype requires four seats at index 1');
return 4;
@@ -99,16 +100,26 @@ test('GameEntry preflight does not call a resolver before its game-owned roomtyp
assert.deepEqual(calls, []);
});
test('assertGameEntry disposes its validation module exactly once', () => {
let disposals = 0;
const entry = makeEntry(() => ({
...makeModule([]),
dispose: () => { disposals++; },
}));
test('declaration checks and constructor have zero factory or disposer side effects', () => {
let created = 0, disposals = 0;
const entry = makeEntry(() => {
created++;
return {...makeModule([]),dispose: () => { disposals++; }};
});
assert.doesNotThrow(() => assertGameEntry(entry));
const session = new GameSessionHost(entry);
assert.equal(created, 0);
assert.equal(disposals, 0);
session.open(makeHost());
assert.equal(created, 1);
session.close();
assert.equal(disposals, 1);
session.open(makeHost());
session.dispose();
assert.equal(created, 2);
assert.equal(disposals, 2);
});
test('GameSessionHost delivers room events and deskinfo to one active module in order', () => {
@@ -186,18 +197,17 @@ test('GameSessionHost preserves create and contract failures when open cleanup s
let createCalls = 0;
const createSessions = new GameSessionHost(makeEntry(() => {
createCalls += 1;
if (createCalls === 1) return makeModule([]);
throw createError;
}));
assert.equal(caughtError(() => createSessions.open(makeHost())), createError);
assert.equal(createSessions.state, 'idle');
assert.equal(createCalls, 1);
let contractCalls = 0;
let contractDisposals = 0;
const contractSessions = new GameSessionHost(makeEntry(() => {
contractCalls += 1;
if (contractCalls === 1) return makeModule([]);
return {
handlePlatformEvent() {},
handleGameMessage() {},
@@ -208,6 +218,7 @@ test('GameSessionHost preserves create and contract failures when open cleanup s
assert.match(caughtError(() => contractSessions.open(makeHost())).message, /implement attach/);
assert.equal(contractDisposals, 1);
assert.equal(contractCalls, 1);
assert.equal(contractSessions.state, 'idle');
});
@@ -218,7 +229,6 @@ test('GameSessionHost preserves attach then dispose failures by identity and occ
let disposals = 0;
const sessions = new GameSessionHost(makeEntry(() => {
createCalls += 1;
if (createCalls === 1) return makeModule([]);
return {
...makeModule([]),
attach() { throw attachError; },
@@ -514,3 +524,19 @@ test('terminal disposal during module creation cleans the returned module withou
assert.equal(attaches, 0);
assert.equal(sessions.state, 'disposed');
});
test('active game projection is exposed read-only and disappears with its owning module',()=>{
const projection={getSnapshot(){return {revision:0,roundPhase:'waiting',selfParticipation:'waiting' as const,scoresBySeat:{},restrictions:{}};},subscribe(){return ()=>{};}};
const session=new GameSessionHost(makeEntry(()=>({...makeModule([]),roomProjection:projection})));
assert.equal(session.roomProjection,null);session.open(makeHost());assert.equal(session.roomProjection,projection);session.close();assert.equal(session.roomProjection,null);
const unsupported=new GameSessionHost(makeEntry(()=>makeModule([])));unsupported.open(makeHost());assert.equal(unsupported.roomProjection,null);unsupported.dispose();
});
test('an undefined module is rejected only when the actual room opens',()=>{
let creations=0;
const entry=makeEntry(()=>{creations++;return undefined as never;});
const sessions=new GameSessionHost(entry);
assert.equal(creations,0);assert.equal(sessions.state,'idle');
assert.throws(()=>sessions.open(makeHost()),/module/);
assert.equal(creations,1);assert.equal(sessions.state,'idle');sessions.close();sessions.dispose();
});