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,21 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {GAME_DEFINITIONS,getGameDefinition} from '../fixtures/game-definitions.ts';
test('each registration binds its own route, rules, presentation and chat',()=>{
for(const definition of Object.values(GAME_DEFINITIONS)){
assert.equal(getGameDefinition(definition.key),definition);
const binding=definition.createGame('configured-game-id');
assert.equal(binding.entry.key,definition.key);
assert.equal(binding.entry.route,definition.route);
assert.equal(binding.entry.gameId,'configured-game-id');
}
const eqw=GAME_DEFINITIONS.erqiwang;
assert.equal(eqw.createPageClass,'ErqiwangCreateRoomView');
assert.equal(eqw.roomViewClass,'ErqiwangRoomView');
assert.equal(eqw.chat.phrases[0].text,'快点吧,我等的花儿都谢了。');
assert.throws(()=>eqw.createGame('id').entry.resolveSeatCount([1,4,1,2]),/二七王/);
assert.equal(GAME_DEFINITIONS.Game_Surface_3.createPageClass,'TemplateCreateRoomView');
assert.equal(GAME_DEFINITIONS.Game_Surface_3.chat.phrases[0].text,'你好');
assert.throws(()=>getGameDefinition('missing'),/Unknown game/);
});
@@ -14,6 +14,32 @@ import {
const roots = [];
test('application composition cannot import or re-export any concrete game',async()=>{
const root=await createFixtureRoot();
await writeFixture(root,'framework/sdk/contracts/value.ts','export type Value=string;');
await writeFixture(root,'games/a/game.ts','export const game=1;');
await writeFixture(root,'app/entry.ts',"export {game} from '../games/a/game.ts';");
const violations=scanImportBoundaries({frameworkDir:join(root,'framework'),gamesDir:join(root,'games'),appDir:join(root,'app')});
assert.equal(violations.length,1);assert.match(violations[0].message,/app composition cannot import/);
});
test('public Cocos code cannot import a concrete game or application registry',async()=>{
const root=await createFixtureRoot();
await writeFixture(root,'framework/sdk/contracts/value.ts','export type Value=string;');
await writeFixture(root,'games/a/game.ts','export const game=1;');
await writeFixture(root,'app/registry.ts','export const registry=1;');
await writeFixture(root,'scripts/Startup.ts',"import {game} from '../games/a/game.ts';import {registry} from '../app/registry.ts';");
const violations=scanImportBoundaries({frameworkDir:join(root,'framework'),gamesDir:join(root,'games'),publicDirs:[join(root,'scripts')],appDir:join(root,'app')});
assert.equal(violations.length,2);assert.ok(violations.every(v=>/public.*games/.test(v.message)));
});
test('games cannot inherit another game implementation',async()=>{
const root=await createFixtureRoot();
await writeFixture(root,'games/a/view.ts','export class View{}');
await writeFixture(root,'games/b/view.ts',"import {View} from '../a/view.ts';export class Other extends View{}");
assert.match(scan(root)[0].message,/another game/);
});
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
@@ -0,0 +1,33 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readdirSync,readFileSync} from 'node:fs';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
import ts from 'typescript';
const root=fileURLToPath(new URL('../../YouleNexus/assets/',import.meta.url));
function files(dir){return readdirSync(dir,{withFileTypes:true}).flatMap(e=>e.isDirectory()?files(join(dir,e.name)):e.name.endsWith('.ts')?[join(dir,e.name)]:[]);}
test('caught technical exceptions cannot be passed directly to player tip callbacks',()=>{
const violations=[];
for(const file of files(root)){
const source=ts.createSourceFile(file,readFileSync(file,'utf8'),ts.ScriptTarget.Latest,true);
function visit(node){
if(ts.isCatchClause(node)&&node.variableDeclaration&&ts.isIdentifier(node.variableDeclaration.name)){
const caught=node.variableDeclaration.name.text;
function inspect(child){
if(ts.isCallExpression(child)&&ts.isPropertyAccessExpression(child.expression)&&['tip','showTips','reportError','kick'].includes(child.expression.name.text)){
let usesCaught=false;
const references=n=>{if(ts.isIdentifier(n)&&n.text===caught)usesCaught=true;ts.forEachChild(n,references);};
child.arguments.forEach(references);
if(usesCaught)violations.push(`${file}:${source.getLineAndCharacterOfPosition(child.getStart()).line+1}`);
}
ts.forEachChild(child,inspect);
}
inspect(node.block);
}
ts.forEachChild(node,visit);
}
visit(source);
}
assert.deepEqual(violations,[]);
});
@@ -0,0 +1,83 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {fileURLToPath} from 'node:url';
import {loadRoomAssetInventory,loadRoomAssetDefinitions,validateRoomAssets,compressUuid,parseRoomAssetDefinitions} from '../../scripts/lib/room-assets.mjs';
const assetsDir=fileURLToPath(new URL('../../YouleNexus/assets/',import.meta.url));
const inventory=()=>loadRoomAssetInventory(assetsDir);
const definitions=loadRoomAssetDefinitions(fileURLToPath(new URL('../../YouleNexus/assets/games',import.meta.url)));
const template=definitions.find(definition=>definition.key==='Game_Surface_3');
const erqiwang=definitions.find(definition=>definition.key==='erqiwang');
const original=inventory();
const copy=()=>structuredClone(original);
const erqiwangPath=erqiwang.resources.createRoom;
const uuidOf=name=>original.scripts.find(script=>script.name===name).uuid;
test('static definition extraction reads the authoritative object without executing game factories',()=>{
const declarations=Object.fromEntries(definitions.map(definition=>[definition.key,definition]));
const text=`throw Error('must never execute'); export const GAME_DEFINITIONS=${JSON.stringify(declarations)} as const satisfies Record<string, GameDefinition>;`;
assert.deepEqual(parseRoomAssetDefinitions(text),definitions);
assert.throws(()=>parseRoomAssetDefinitions(text.replace('"key":"erqiwang"','"key":"another-game"')),/registration erqiwang differs/);
assert.throws(()=>parseRoomAssetDefinitions(text.replace('"createPageClass":"ErqiwangCreateRoomView"','"createPageClass":getClassName()')),/createPageClass must be a nonempty string literal/);
});
test('script UUID compression agrees with an actual serialized component and inherited page metadata',()=>{
const script=original.scripts.find(script=>script.name==='ErqiwangCreateRoomView');
assert.ok(script.contracts.includes('CreateRoomPage'));
assert.ok(original.assets.find(asset=>asset.path===erqiwangPath).objects.some(object=>object.__type__===compressUuid(script.uuid)));
});
test('current template and erqiwang assets have unique pages and matching composition references',()=>{
assert.deepEqual(validateRoomAssets(original,definitions),[]);
});
test('duplicate template component on erqiwang prefab is rejected',()=>{
const data=copy();
const prefab=data.assets.find(asset=>asset.path===erqiwangPath);
prefab.objects.push({__type__:compressUuid(uuidOf('TemplateCreateRoomView')),node:{__id__:1}});
assert.match(validateRoomAssets(data,definitions).join('\n'),/exactly one CreateRoomPage.*found 2/);
});
test('wrong game page and missing page are rejected',()=>{
const data=copy();
const page=data.assets.find(asset=>asset.path===erqiwangPath).objects.find(object=>object.__type__===compressUuid(uuidOf('ErqiwangCreateRoomView')));
page.__type__=compressUuid(uuidOf('TemplateCreateRoomView'));
assert.match(validateRoomAssets(data,definitions).join('\n'),/expected ErqiwangCreateRoomView.*TemplateCreateRoomView/);
page.__type__='cc.Component';
assert.match(validateRoomAssets(data,definitions).join('\n'),/exactly one CreateRoomPage.*found 0/);
});
test('serialized composition cannot retain private prefab references',()=>{
const data=copy();
const composition=data.assets.flatMap(asset=>asset.objects).find(object=>object.gameKey==='erqiwang');
composition.createRoom={__uuid__:data.assets.find(asset=>asset.path===template.resources.createRoom).uuid};
assert.match(validateRoomAssets(data,definitions).join('\n'),/createRoom.*must not be a serialized/);
composition.room={__uuid__:'00000000-0000-0000-0000-000000000000'};
assert.match(validateRoomAssets(data,definitions).join('\n'),/room.*must not be a serialized/);
});
test('unknown configured game, unresolved script and duplicate room views are rejected',()=>{
const data=copy();
const composition=data.assets.flatMap(asset=>asset.objects).find(object=>object.gameKey==='erqiwang');
composition.gameKey='missing-game';
const room=data.assets.find(asset=>asset.path===erqiwang.resources.room);
room.objects.push({__type__:compressUuid(uuidOf('TemplateRoomView')),node:{__id__:1}});
data.assets.find(asset=>asset.path===erqiwangPath).objects.push({__type__:compressUuid('00000000-0000-0000-0000-000000000001'),node:{__id__:1}});
const errors=validateRoomAssets(data,definitions).join('\n');
assert.match(errors,/unknown game missing-game/);
assert.match(errors,/unresolved script/);
assert.match(errors,/exactly one room view.*found 2/);
});
test('inheritance resolves the erqiwang room view through its shared public base',()=>{
const script=original.scripts.find(script=>script.name==='ErqiwangRoomView');
assert.ok(script.contracts.includes('CommonRoomViewBase'));
});
test('component on a child node cannot satisfy the platform root lookup',()=>{
const data=copy();
const page=data.assets.find(asset=>asset.path===erqiwangPath).objects.find(object=>object.__type__===compressUuid(uuidOf('ErqiwangCreateRoomView')));
page.node={__id__:2};
assert.match(validateRoomAssets(data,definitions).join('\n'),/must be attached to prefab root/);
});
test('an orphan component record cannot satisfy the platform root lookup',()=>{
const data=copy();
const prefab=data.assets.find(asset=>asset.path===erqiwangPath);
const pageIndex=prefab.objects.findIndex(object=>object.__type__===compressUuid(uuidOf('ErqiwangCreateRoomView')));
prefab.objects[prefab.objects[0].data.__id__]._components=prefab.objects[prefab.objects[0].data.__id__]._components.filter(reference=>reference.__id__!==pageIndex);
assert.match(validateRoomAssets(data,definitions).join('\n'),/not attached in the root component list/);
});
@@ -0,0 +1,34 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync,existsSync} from 'node:fs';
import {getGameDefinition,gameBundleName,gameResourcePath} from '../../YouleNexus/assets/app/composition/game-definitions.ts';
test('selected entry resolves through the injected class lookup without a game registry',()=>{
const definition={key:'third_game'} as any;
const names:string[]=[];
assert.equal(getGameDefinition('third_game',name=>{names.push(name);return {definition};}),definition);
assert.deepEqual(names,['GameEntry_third_game']);
assert.throws(()=>getGameDefinition('third_game',()=>null),/entry/);
assert.throws(()=>getGameDefinition('third_game',()=>({definition:{key:'other'}} as any)),/mismatch/);
assert.throws(()=>gameBundleName('../other'),/gameKey/);
assert.equal(gameBundleName('third_game'),'game-third_game');
});
test('each game root and nested version bundle have explicit ownership and imported entries',()=>{
for(const [directory,key] of [['template','Game_Surface_3'],['erqiwang','erqiwang']]){
const root=new URL('../../YouleNexus/assets/games/'+directory,import.meta.url);
const game=JSON.parse(readFileSync(new URL(root.href+'.meta'),'utf8')).userData;
const version=JSON.parse(readFileSync(new URL(root.href+'/resources.meta'),'utf8')).userData;
assert.equal(game.isBundle,true);assert.equal(game.bundleName,'game-'+key);
assert.equal(version.isBundle,true);assert.equal(version.bundleName,'version-'+key);
assert.ok(version.priority>game.priority,'nested version assets must belong to the version bundle');
assert.ok(existsSync(new URL(root.href+'/GameEntry.ts.meta')),'entry must be imported by asset-db');
}
});
test('private resource paths must remain inside the selected game root',()=>{
assert.equal(gameResourcePath('games/third','games/third/Room.prefab'),'Room');
for(const resource of ['games/other/Room.prefab','games/third/../other/Room.prefab','games/third/Room.scene']){
assert.throws(()=>gameResourcePath('games/third',resource),/resource/);
}
});