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
@@ -26,7 +26,7 @@ import {
const GAME_ROOMTYPE = JSON.parse(readFileSync(
new URL('../fixtures/contracts/player-login-room.json', import.meta.url), 'utf8',
)).data.roomtype as readonly unknown[];
)).data.roomtype as import('../../YouleNexus/assets/framework/sdk/contracts/roomtype.ts').Roomtype;
test('browser-like loader evaluates the emitted harness without Node builtin modules', () => {
const source = readFileSync(new URL(
@@ -74,7 +74,7 @@ test('assertGameContract accepts a fixed-four game with its real nested roomtype
));
assert.equal(invalidations, 2);
assert.equal(restoredDeskinfo.length, 0);
assert.ok(nextModuleId >= 2);
assert.equal(nextModuleId, 4, 'two explicit isolation fixtures and two conformance sessions; no constructor probes');
});
const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {createGameRoomProjection} from '../../YouleNexus/assets/framework/sdk/game-room-projection.ts';
import {gameAllowsRoomAction} from '../../YouleNexus/assets/framework/presentation/game-room-presentation.ts';
test('game owns atomic complete projections and exposes no consumer setter',()=>{
const initial={roundPhase:'waiting',selfParticipation:'waiting' as const,scoresBySeat:{0:10},restrictions:{}};
const owner=createGameRoomProjection(initial);const source=owner.source;const versions:any[]=[];
source.subscribe(value=>versions.push(value));owner.commit({roundPhase:'playing',selfParticipation:'playing',scoresBySeat:{0:20},restrictions:{prepare:false}});
assert.deepEqual(versions.map(v=>[v.revision,v.roundPhase,v.scoresBySeat[0]]),[[0,'waiting',10],[1,'playing',20]]);
initial.scoresBySeat[0]=999;assert.equal(versions[0].scoresBySeat[0],10);
assert.equal('commit' in source,false);assert.ok(Object.isFrozen(source.getSnapshot().scoresBySeat));
assert.equal(gameAllowsRoomAction(false,source.getSnapshot(),'exit'),false);assert.equal(gameAllowsRoomAction(true,source.getSnapshot(),'prepare'),false);
assert.throws(()=>owner.commit({...initial,restrictions:{exit:true} as any}),/disable/);assert.equal(source.getSnapshot().revision,1);
owner.dispose();assert.throws(()=>owner.commit(initial),/disposed/);assert.throws(()=>source.subscribe(()=>{}),/disposed/);
});
test('reentrant projection commits notify complete versions in order and isolate listener failure',()=>{
const initial={roundPhase:'waiting',selfParticipation:'waiting' as const,scoresBySeat:{},restrictions:{}};const owner=createGameRoomProjection(initial);const seen:number[]=[];
owner.source.subscribe(p=>{if(p.revision===1){owner.commit({...initial,roundPhase:'finished'});throw Error('listener failed');}});
owner.source.subscribe(p=>seen.push(p.revision));assert.throws(()=>owner.commit({...initial,roundPhase:'playing'}),/notification/);assert.deepEqual(seen,[0,1,2]);
});
@@ -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();
});