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,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/);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user