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
@@ -1,7 +1,7 @@
import { remoteFixture } from '../../fixtures/remote-config.ts';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from '../../fixtures/build-identity.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
@@ -1,7 +1,7 @@
import { remoteFixture } from '../../fixtures/remote-config.ts';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BUILD_IDENTITY } from '../../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from '../../fixtures/build-identity.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
@@ -12,3 +12,9 @@ test('owned rooms use legacy cache, order, and server outcome lifecycle',()=>{
history.joined({state:0,isowner:1,roomcode:654321});assert.deepEqual(history.read(),[]);
data.set('tsgame_roomlist_ga','bad');assert.throws(()=>history.read());
});
test('confirmed break removes only returned roomcode and absent roomcode leaves cache intact',()=>{
const data=new Map<string,string>();const history=new OwnedRoomHistory({getItem:k=>data.get(k)??null,setItem:(k,v)=>{data.set(k,v);}},'g','a');
history.exited({isowner:1,roomcode:'123456'},0);history.exited({isowner:1,roomcode:'654321'},0);
history.broken({});assert.deepEqual(history.read(),[654321,123456]);history.broken({roomcode:'123456'});assert.deepEqual(history.read(),[654321]);
});
@@ -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/);
}
});
@@ -1,3 +1,4 @@
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
@@ -10,7 +11,7 @@ test('resolveBootstrap:显式 hostKind 透传严格 RuntimeConfig,并保留
const fetcher: ConfigFetcher = {
async fetch() { return { player_server_tcp: '5.5.5.5:5000' }; },
};
const result = await resolveBootstrap({
const result = await resolveBootstrap({buildIdentity:BUILD_IDENTITY,
startupConfig:STARTUP_CONFIG, hostKind: 'h5', win: {}, search: '', fetcher,
});
assert.equal(result.source, 'remote');
@@ -21,7 +22,7 @@ test('resolveBootstrap:显式 hostKind 透传严格 RuntimeConfig,并保留
test('facade forwards the entire injected source and derives its compatibility label', async () => {
for (const useLocalServer of [true,false]) {
const startupConfig={...STARTUP_CONFIG,mode:'release' as const,useLocalServer,gameserver:'https://fixture.example/config.txt'};
const result=await resolveBootstrap({startupConfig,isNative:false,win:{},search:'?mode=debug&profile=unknown&profile=local',fetcher:{async fetch(url){assert.equal(url,startupConfig.gameserver);return {player_server_tcp:'remote.example:3088'};}}});
const result=await resolveBootstrap({buildIdentity:BUILD_IDENTITY,startupConfig,isNative:false,win:{},search:'?mode=debug&profile=unknown&profile=local',fetcher:{async fetch(url){assert.equal(url,startupConfig.gameserver);return {player_server_tcp:'remote.example:3088'};}}});
assert.deepEqual(result.startupConfig,startupConfig); assert.equal(result.mode,'release');
assert.equal(result.profile.name,useLocalServer?'local':'prod');
assert.deepEqual(result.servers,useLocalServer?STARTUP_CONFIG.localServers:['ws://remote.example:3088']);
@@ -0,0 +1,33 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {getGameDefinition} from '../fixtures/game-definitions.ts';
import {createBuildIdentity,WEB_MARKET_ID} from '../../YouleNexus/assets/app/composition/build-identity.ts';
import {parseVersionManifest} from '../../YouleNexus/assets/app/composition/version-manifest.js';
import {resolveRuntimeConfig} from '../../YouleNexus/assets/framework/config/runtime-config.ts';
import {STARTUP_CONFIG} from '../../YouleNexus/assets/framework/config/profiles.ts';
function manifest(key:string,dir:string){
const definition=getGameDefinition(key);
assert.equal(definition.config.versionResource,key+'/version');
return parseVersionManifest(readFileSync(new URL('../../YouleNexus/assets/games/'+dir+'/resources/'+definition.config.versionResource+'.xml',import.meta.url),'utf8'));
}
test('selected game reads identity and both versions from its XML',()=>{
const manifests=[manifest('erqiwang','erqiwang'),manifest('Game_Surface_3','template')];
for(const source of manifests){const identity=createBuildIdentity(source);
assert.equal(identity.gameid,source.gameid);assert.equal(identity.agentid,source.agentid);
assert.equal(identity.channelid,source.channelid);assert.equal(identity.version,source.versionCode);
assert.equal(identity.marketid,WEB_MARKET_ID);assert.ok(Object.isFrozen(identity));
}
assert.notEqual(manifests[0].gameid,manifests[1].gameid);
assert.throws(()=>getGameDefinition('missing'),/Unknown game/);
assert.throws(()=>createBuildIdentity({...manifests[0],gameid:''}),/gameid/);
});
test('XML edits drive Web identity while native channel remains native-owned',async()=>{
const source={...manifest('erqiwang','erqiwang'),gameid:'xml-game',agentid:'xml-agent',channelid:'xml-channel',versionCode:37,version:'3.7'};
const options={buildIdentity:createBuildIdentity(source),startupConfig:{...STARTUP_CONFIG,configSource:'remote' as const},
search:'',fetcher:{async fetch(){return {player_server_tcp:'remote.example:3088'};}}};
const web=await resolveRuntimeConfig({...options,hostKind:'h5',win:{}});
assert.deepEqual(web.identity,{agentid:'xml-agent',channelid:'xml-channel',gameid:'xml-game',version:37,marketid:WEB_MARKET_ID});
const native=await resolveRuntimeConfig({...options,hostKind:'uAgent_3',win:{app_agent:'native-a',app_channel:'native-c',app_market:9} as any});
assert.deepEqual(native.identity,{agentid:'native-a',channelid:'native-c',marketid:9,gameid:'xml-game',version:37});
});
@@ -1,7 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveIdentity } from '../../YouleNexus/assets/framework/config/identity.ts';
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
test('BUILD_IDENTITY 含全部字段且 gameid 为固定 token', () => {
assert.ok(BUILD_IDENTITY.gameid.length > 0);
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as config from '../../YouleNexus/assets/framework/config/remote-config.ts';
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
test('launchWaitTime follows legacy default, hall own field and truthy hierarchy precedence', () => {
const resolve = (config as any).resolveLaunchWaitTime;
@@ -12,7 +12,7 @@ import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/asse
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
import type { ResolveRuntimeConfigOptions, RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config.ts';
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
function options(search = ''): ResolveRuntimeConfigOptions & {
fetcher: ConfigFetcher & { calls: number };
@@ -24,7 +24,7 @@ function options(search = ''): ResolveRuntimeConfigOptions & {
return { player_server_tcp: 'remote.example:3088' };
},
};
return { startupConfig:STARTUP_CONFIG, hostKind: 'h5', win: {}, search, fetcher };
return { buildIdentity:BUILD_IDENTITY, startupConfig:STARTUP_CONFIG, hostKind: 'h5', win: {}, search, fetcher };
}
test('local debug fetches remote config before choosing authoritative local servers', async () => {
@@ -179,7 +179,7 @@ test('local consumers validate the injected source snapshot and its exact target
test('local startup validates the selected project txt source', async () => {
const localFetcher={async fetch(){return {showVisitor:1};}};
const context=await resolveLocalStartup({startupConfig:{...STARTUP_CONFIG,configSource:'local'},hostKind:'h5',win:{},search:'',
const context=await resolveLocalStartup({buildIdentity:BUILD_IDENTITY,startupConfig:{...STARTUP_CONFIG,configSource:'local'},hostKind:'h5',win:{},search:'',
fetcher:{async fetch(){throw Error('remote forbidden');}},localFetcher});
assert.equal(context.config.source,'local');
assert.equal(context.config.gameserver,STARTUP_CONFIG.localConfigResource);
@@ -1,3 +1,4 @@
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
@@ -28,7 +29,7 @@ test('resolveRuntimeConfig:把 cacheBust 注入唯一的 HTTP 请求构造点'
return new Response('{"player_server_tcp":"10.0.0.1:3088"}');
});
await resolveRuntimeConfig({
await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG,mode:'release',useLocalServer:false},
hostKind: 'h5',
win: {},
@@ -3,7 +3,7 @@ import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/asse
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
import type { ConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config.ts';
@@ -20,7 +20,7 @@ const REMOTE = { player_server_tcp: ['5.5.5.5:5000', '5.5.5.5:5000'] };
test('h5:只读 query 身份,不访问原生宿主,并生成严格 remote 配置', async () => {
const fetcher = returning(REMOTE);
const throwingSettings = new Proxy({}, { get() { throw new Error('native access forbidden'); } });
const result = await resolveRuntimeConfig({
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false},
hostKind: 'h5',
win: { settings: throwingSettings },
@@ -43,7 +43,7 @@ test('h5:只读 query 身份,不访问原生宿主,并生成严格 remote
test('native-settings:使用四个原生方法,gameconfig 是唯一 gameserver 注入名', async () => {
const calls: string[] = [];
const fetcher = returning(REMOTE);
const result = await resolveRuntimeConfig({
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false},
hostKind: 'native-settings',
win: {
@@ -67,7 +67,7 @@ test('native-settings:使用四个原生方法,gameconfig 是唯一 gameserv
test('uAgent_3:只读 app_<name>,settings 不参与身份或 gameserver', async () => {
const fetcher = returning(REMOTE);
const result = await resolveRuntimeConfig({
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false},
hostKind: 'uAgent_3',
win: {
@@ -86,7 +86,7 @@ test('uAgent_3:只读 app_<name>,settings 不参与身份或 gameserver', as
test('native-settings:不完整最终身份显式拒绝,不拿 build identity 兜底', async () => {
await assert.rejects(
resolveRuntimeConfig({
resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false}, hostKind: 'native-settings', win: {}, search: '', fetcher: returning(REMOTE),
}),
/identity.*agentid.*channelid/i,
@@ -105,14 +105,14 @@ test('uAgent_3:所有允许 number 的身份字段都拒绝 NaN 和无穷值',
[sample.key]: sample.value,
};
await assert.rejects(
resolveRuntimeConfig({
resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false}, hostKind: 'uAgent_3', win, search: '', fetcher: returning(REMOTE),
}),
new RegExp(`identity.*${sample.field}.*finite`, 'i'),
);
}
await assert.rejects(
resolveRuntimeConfig({
resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false},
hostKind: 'h5',
win: {},
@@ -125,7 +125,7 @@ test('uAgent_3:所有允许 number 的身份字段都拒绝 NaN 和无穷值',
test('uAgent_3:拒绝宿主提供的非法身份字段类型', async () => {
await assert.rejects(
resolveRuntimeConfig({
resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false},
hostKind: 'uAgent_3',
win: { app_agent: true, app_channel: 'C', app_market: 4, app_gameconfig: '' },
@@ -138,7 +138,7 @@ test('uAgent_3:拒绝宿主提供的非法身份字段类型', async () => {
test('显式 local debug:远程配置保留内存,连接服务器来自 profile', async () => {
const fetcher = returning(REMOTE);
const result = await resolveRuntimeConfig({
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: STARTUP_CONFIG, hostKind: 'h5', win: {}, search: '?profile=local', fetcher,
});
assert.equal(result.source, 'remote');
@@ -152,7 +152,7 @@ test('显式 local debug:远程配置保留内存,连接服务器来自 prof
test('远程抓取失败显式报错,不降级 direct', async () => {
const fetcher: ConfigFetcher = { async fetch() { throw new Error('net down'); } };
await assert.rejects(
resolveRuntimeConfig({ startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false}, hostKind: 'h5', win: {}, search: '', fetcher }),
resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY, startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false}, hostKind: 'h5', win: {}, search: '', fetcher }),
/远程配置抓取失败/,
);
});
@@ -160,7 +160,7 @@ test('远程抓取失败显式报错,不降级 direct', async () => {
test('release ignores local profile but still fetches and selects remote servers', async () => {
const fetcher = returning(REMOTE);
const result = await resolveRuntimeConfig({ startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false}, hostKind: 'h5', win: {}, search: '?profile=local', fetcher });
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY, startupConfig: {...STARTUP_CONFIG, mode: 'release', useLocalServer: false}, hostKind: 'h5', win: {}, search: '?profile=local', fetcher });
assert.equal(fetcher.calls.length, 1);
assert.deepEqual(result.servers, ['ws://5.5.5.5:5000', 'ws://5.5.5.5:5000']);
assert.equal(result.isDebugger, false);
@@ -171,14 +171,14 @@ test('JSON parse errors retain their classification through the runtime boundary
const { ConfigParseError } = await import('../../YouleNexus/assets/framework/config/remote-config.ts');
const error = new ConfigParseError('invalid JSON');
const fetcher = { async fetch() { throw error; } };
await assert.rejects(resolveRuntimeConfig({ startupConfig: STARTUP_CONFIG, hostKind: 'h5', win: {}, search: '?profile=local', fetcher }),
await assert.rejects(resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY, startupConfig: STARTUP_CONFIG, hostKind: 'h5', win: {}, search: '?profile=local', fetcher }),
actual => actual === error);
});
test('local consumes explicit loopback targets after valid remote JSON even when remote candidates are absent', async () => {
const raw = { visitor_server_tcp: 'unused.example:1234', notice: 'kept' };
const fetcher = returning(raw);
const result = await resolveRuntimeConfig({ startupConfig: STARTUP_CONFIG, hostKind: 'h5', win: {}, search: '?profile=local', fetcher });
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY, startupConfig: STARTUP_CONFIG, hostKind: 'h5', win: {}, search: '?profile=local', fetcher });
assert.equal(fetcher.calls.length, 1);
assert.deepEqual(result.servers, ['ws://127.0.0.1:3088']);
assert.strictEqual(result.rawConfig, raw);
@@ -188,7 +188,7 @@ test('local consumes explicit loopback targets after valid remote JSON even when
test('remote profiles reject missing game/player addresses without selecting visitor', async () => {
for (const mode of ['debug', 'release'] as const) {
const fetcher = returning({ visitor_server_tcp: 'unused.example:1234' });
await assert.rejects(resolveRuntimeConfig({ startupConfig: {...STARTUP_CONFIG, mode, useLocalServer: false}, hostKind: 'h5', win: {}, search: '', fetcher }), /game_server_tcp\/player_server_tcp/);
await assert.rejects(resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY, startupConfig: {...STARTUP_CONFIG, mode, useLocalServer: false}, hostKind: 'h5', win: {}, search: '', fetcher }), /game_server_tcp\/player_server_tcp/);
assert.equal(fetcher.calls.length, 1);
}
});
@@ -196,34 +196,34 @@ test('remote profiles reject missing game/player addresses without selecting vis
for (const mode of ['debug','release'] as const) for (const useLocalServer of [true,false]) test('independent source '+mode+'/'+useLocalServer+' ignores URL selectors', async () => {
const fetcher = returning(useLocalServer ? {visitor_server_tcp:'visitor:1'} : REMOTE);
const source = {...STARTUP_CONFIG,mode,useLocalServer};
const result = await resolveRuntimeConfig({startupConfig:source,hostKind:'h5',win:{},search:'?mode=debug&mode=release&profile=prod&profile=unknown',fetcher});
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,startupConfig:source,hostKind:'h5',win:{},search:'?mode=debug&mode=release&profile=prod&profile=unknown',fetcher});
assert.equal(fetcher.calls.length,1); assert.equal(result.mode,mode); assert.equal(result.isDebugger,mode==='debug');
assert.deepEqual(result.servers,useLocalServer ? STARTUP_CONFIG.localServers : ['ws://5.5.5.5:5000','ws://5.5.5.5:5000']);
assert.deepEqual(result.startupConfig,source); assert.ok(Object.isFrozen(result.startupConfig));
source.localServers=['ws://localhost:9999']; assert.deepEqual(result.startupConfig.localServers,STARTUP_CONFIG.localServers);
});
test('default runtime starts without query configuration', async () => {
const result=await resolveRuntimeConfig({startupConfig:STARTUP_CONFIG,hostKind:'h5',win:{},search:'',fetcher:returning({notice:'kept'})});
const result=await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,startupConfig:STARTUP_CONFIG,hostKind:'h5',win:{},search:'',fetcher:returning({notice:'kept'})});
assert.deepEqual(result.startupConfig,STARTUP_CONFIG); assert.deepEqual(result.servers,STARTUP_CONFIG.localServers);
});
test('invalid source fails before remote fetch, including null and missing fields', async () => {
for (const patch of [null, {useLocalServer: undefined}, {gameserver: ''}, {localServers: ['ws://elsewhere:3088']}]) {
const fetcher=returning(REMOTE);
await assert.rejects(resolveRuntimeConfig({startupConfig: patch === null ? null as any : {...STARTUP_CONFIG,...patch} as any,hostKind:'h5',win:{},search:'',fetcher}),/startup config/i);
await assert.rejects(resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,startupConfig: patch === null ? null as any : {...STARTUP_CONFIG,...patch} as any,hostKind:'h5',win:{},search:'',fetcher}),/startup config/i);
assert.equal(fetcher.calls.length,0);
}
});
for (const mode of ['debug','release'] as const) for (const useLocalServer of [true,false]) test('remote failure never falls back '+mode+'/'+useLocalServer, async () => {
const fetcher={async fetch(){throw new Error('remote unavailable');}};
await assert.rejects(resolveRuntimeConfig({startupConfig:{...STARTUP_CONFIG,mode,useLocalServer},hostKind:'h5',win:{},search:'',fetcher}),/远程配置抓取失败/);
await assert.rejects(resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,startupConfig:{...STARTUP_CONFIG,mode,useLocalServer},hostKind:'h5',win:{},search:'',fetcher}),/远程配置抓取失败/);
});
// 本地来源必须在运行时边界隔离网络读取,并复用正式配置的层级取值规则。
test('local txt selects one source, ignores gameconfig and preserves scoped values', async () => {
const remote = returning(REMOTE);
const local = returning({showVisitor: 1, agentlist: [{agentid: BUILD_IDENTITY.agentid, deviceLogin: true}]});
const result = await resolveRuntimeConfig({startupConfig:{...STARTUP_CONFIG, configSource:'local'},
const result = await resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,startupConfig:{...STARTUP_CONFIG, configSource:'local'},
hostKind:'h5',win:{},search:'?gameconfig=unwanted-host',fetcher:remote,localFetcher:local});
assert.equal(result.source,'local');
assert.deepEqual(remote.calls,[]);
@@ -236,7 +236,7 @@ test('local txt selects one source, ignores gameconfig and preserves scoped valu
test('local txt errors never fall back to remote, release rejects before I/O', async () => {
const remote=returning(REMOTE); let localCalls=0;
const localFetcher={async fetch(){localCalls++;throw Error('missing txt');}};
const options={startupConfig:{...STARTUP_CONFIG,configSource:'local' as const},hostKind:'h5' as const,win:{},search:'',fetcher:remote,localFetcher};
const options={buildIdentity:BUILD_IDENTITY,startupConfig:{...STARTUP_CONFIG,configSource:'local' as const},hostKind:'h5' as const,win:{},search:'',fetcher:remote,localFetcher};
await assert.rejects(resolveRuntimeConfig(options),/本地配置读取失败/);
assert.equal(localCalls,1);assert.equal(remote.calls.length,0);
await assert.rejects(resolveRuntimeConfig({...options,startupConfig:{...options.startupConfig,mode:'release'}}),/configSource/);
@@ -246,10 +246,13 @@ test('local txt errors never fall back to remote, release rejects before I/O', a
test('local txt and socket target are independent; remote mode never touches local loader', async () => {
const remote=returning(REMOTE),local=returning(REMOTE);
const options={hostKind:'h5' as const,win:{},search:'',fetcher:remote,localFetcher:local};
const options={buildIdentity:BUILD_IDENTITY,hostKind:'h5' as const,win:{},search:'',fetcher:remote,localFetcher:local};
const result=await resolveRuntimeConfig({...options,startupConfig:{...STARTUP_CONFIG,configSource:'local',useLocalServer:false}});
assert.deepEqual(result.servers,['ws://5.5.5.5:5000','ws://5.5.5.5:5000']);
assert.equal(remote.calls.length,0);assert.equal(local.calls.length,1);
await resolveRuntimeConfig({...options,startupConfig:{...STARTUP_CONFIG,configSource:'remote'}});
assert.equal(remote.calls.length,1);assert.equal(local.calls.length,1);
});
test('application injects selected game identity',async()=>{for(const gameid of ['one','two']){const result=await resolveRuntimeConfig({startupConfig:STARTUP_CONFIG,buildIdentity:{...BUILD_IDENTITY,gameid},hostKind:'h5',win:{},search:'?gameid=other',fetcher:returning(REMOTE)});assert.equal(result.identity.gameid,gameid);}});
test('missing application identity fails before I/O',async()=>{const fetcher=returning(REMOTE);await assert.rejects(resolveRuntimeConfig({startupConfig:STARTUP_CONFIG,hostKind:'h5',win:{},search:'',fetcher} as any),/buildIdentity/);assert.equal(fetcher.calls.length,0);});
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
const {parseVersionManifest}=createRequire(import.meta.url)('../../YouleNexus/assets/app/composition/version-manifest.js');
const xml='<?xml version="1.0" encoding="UTF-8"?><game><agent id="a" name="代理"/><game id="g" name="游戏 &amp; 测试"/><channel id="c" name="渠道"/><version value="8" name="1.8"/></game>';
test('legacy XML maps numeric code separately from display version',()=>{
const result=parseVersionManifest(xml);assert.equal(result.versionCode,8);assert.equal(result.version,'1.8');assert.equal(result.gameid,'g');assert.equal(result.gameName,'游戏 & 测试');assert.ok(Object.isFrozen(result));
});
test('bad XML and missing or ambiguous source fields fail explicitly',()=>{
for(const bad of ['',xml.replace('value="8"','value="1.8"'),xml.replace('value="8"','value="9007199254740992"'),xml.replace('name="1.8"','name=""'),xml.replace('<game id="g"','<game id=""'),xml.replace('</game>','<version value="9" name="2"/></game>'),xml.replace('</game>','</wrong>'),'<!DOCTYPE game>'+xml,xml.replace('id="a"','id="a" id="b"')])assert.throws(()=>parseVersionManifest(bad),/version.xml/);
});
@@ -0,0 +1,6 @@
import type {ChannelIdentity} from '../../YouleNexus/assets/framework/config/identity.ts';
/** Test-only identity, independent of application selection. */
export const BUILD_IDENTITY:Readonly<ChannelIdentity> = Object.freeze({
agentid:'1B2h0ccl205c390Y28m1Ajdplkuu4wgy',channelid:'aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG',
gameid:'J9r4022t80pxlb01zOK76DN1WD4QewFl',marketid:4,version:1,
});
@@ -0,0 +1,9 @@
import {GAME_DEFINITIONS as template} from '../../YouleNexus/assets/games/template/game-definition.ts';
import {GAME_DEFINITIONS as erqiwang} from '../../YouleNexus/assets/games/erqiwang/game-definition.ts';
import type {GameDefinition} from '../../YouleNexus/assets/framework/sdk/contracts/game-definition.ts';
/** Test-only inventory, excluded from Cocos assets. */
export const GAME_DEFINITIONS={...template,...erqiwang};
export function getGameDefinition(key:string):GameDefinition {
if(!Object.prototype.hasOwnProperty.call(GAME_DEFINITIONS,key))throw Error('Unknown game: '+key);
return GAME_DEFINITIONS[key as keyof typeof GAME_DEFINITIONS];
}
@@ -1,4 +1,4 @@
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from './build-identity.ts';
import { createRemoteConfig } from '../../YouleNexus/assets/framework/config/remote-config.ts';
import type { ChannelIdentity } from '../../YouleNexus/assets/framework/config/identity.ts';
@@ -2,7 +2,7 @@ import { STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profile
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveLocalStartup } from '../../YouleNexus/assets/framework/config/local-startup.ts';
import { BUILD_IDENTITY } from '../../YouleNexus/assets/framework/config/sources/defaults.ts';
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
import { localAccountKey, type LocalStoragePort } from '../../YouleNexus/assets/framework/adapters/local/local-storage.ts';
import { createLocalLoginSources } from '../../YouleNexus/assets/framework/adapters/local/login-sources.ts';
import { buildLoginRequest } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
@@ -14,7 +14,7 @@ class MapStorage implements LocalStoragePort {
}
function resolve(search = '?profile=local') {
return resolveLocalStartup({ startupConfig:{...STARTUP_CONFIG,configSource:'remote'}, hostKind: 'h5', win: {}, search,
return resolveLocalStartup({buildIdentity:BUILD_IDENTITY, startupConfig:{...STARTUP_CONFIG,configSource:'remote'}, hostKind: 'h5', win: {}, search,
fetcher: { async fetch() { return { player_server_tcp: 'remote.example:3088' }; } } });
}
@@ -1,3 +1,4 @@
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
@@ -123,7 +124,7 @@ test('real modern vertical replay preserves wire contracts, room ordering and te
};
const runtime = new PlatformRuntime({
gameEntry: entry,
resolveRuntimeConfig: () => resolveRuntimeConfig({
resolveRuntimeConfig: () => resolveRuntimeConfig({buildIdentity:BUILD_IDENTITY,
startupConfig: {...STARTUP_CONFIG,mode:'release',useLocalServer:false}, hostKind: 'native-settings', search: '',
win: { settings: {
getothername(name: string) {
@@ -107,13 +107,14 @@ function round(value) {
function sourceSignature(spirit) {
const isFullScreen = spirit.width === 1280 && spirit.height === 720;
const autoSizeText = spirit.text !== undefined && spirit.autoSize === 1;
return JSON.stringify([
spirit.name,
round(spirit.x + spirit.width / 2 - 640),
round(360 - (spirit.y + spirit.height / 2)),
isFullScreen ? 1600 : spirit.width,
isFullScreen ? 720 : spirit.height,
spirit.visible,
autoSizeText ? null : isFullScreen ? 1600 : spirit.width,
autoSizeText ? null : isFullScreen ? 720 : spirit.height,
[3112,3114,3117].includes(spirit.id) ? false : spirit.visible,
]);
}
@@ -122,18 +123,28 @@ function prefabSignature(prefab, node) {
.map((ref) => prefab[ref.__id__])
.find((component) => component.__type__ === 'cc.UITransform');
assert.ok(uiTransformRef, `${node._name} should have UITransform`);
const autoSizeText = nodeComponent(prefab,node,'cc.Label')?._overflow === 0;
return JSON.stringify([
node._name,
round(node._lpos.x),
round(node._lpos.y),
uiTransformRef._contentSize.width,
uiTransformRef._contentSize.height,
autoSizeText ? null : uiTransformRef._contentSize.width,
autoSizeText ? null : uiTransformRef._contentSize.height,
node._active,
]);
}
test('Layer418 prefab preserves every source spirit and its layout contract', () => {
// Approved replacements: EditBox input; dynamic phrase rows; expression grid;
// history row template; scrollable announcements and their dynamic cost label.
// All other source art stays. Spirit3117 retains the user's authored font size 16.
const dynamicSourceIds = new Set([
204, 206, 207, 208, 209, 210, 211, 212,
...Array.from({length:20},(_,index)=>528+index),
467, 696, 697, 163, 164, 165, 480, 3109, 3110, 3117,
]);
test('Layer418 static source art preserves its layout and sprite contract', () => {
const sourceSpirits = parseSourceSpirits(fs.readFileSync(sourcePath, 'utf8'));
const objectList = JSON.parse(fs.readFileSync(objectDataPath, 'utf8')).ObjectList;
const imageFileList = JSON.parse(fs.readFileSync(imageDataPath, 'utf8')).ImageFileList;
@@ -141,31 +152,21 @@ test('Layer418 prefab preserves every source spirit and its layout contract', ()
const nodes = prefab.filter((entry) => entry.__type__ === 'cc.Node');
const root = nodes.find((node) => node._name === 'Layer418_ChatPannel');
const group = nodes.find((node) => node._name === 'group-30');
const spiritNodes = nodes.filter(
(node) => node._name !== 'Layer418_ChatPannel' && node._name !== 'group-30',
);
assert.equal(sourceSpirits.length, 54);
assert.equal(nodes.length, 56);
assert.equal(spiritNodes.length, 54);
const staticSpirits = sourceSpirits.filter(spirit=>!dynamicSourceIds.has(spirit.id));
const staticNames = new Set(staticSpirits.map(spirit=>spirit.name));
const spiritNodes = nodes.filter(node=>staticNames.has(node._name));
assert.equal(spiritNodes.length,staticSpirits.length);
assert.deepEqual(root._children.map((ref) => prefab[ref.__id__]._name), ['group-30']);
assert.deepEqual(
group._children.map((ref) => prefab[ref.__id__]._name),
[...sourceSpirits].sort((a, b) => a.index - b.index).map((spirit) => spirit.name),
group._children.map((ref) => prefab[ref.__id__]._name).filter(name=>staticNames.has(name)),
[...staticSpirits].sort((a, b) => a.index - b.index).map((spirit) => spirit.name),
);
assert.deepEqual(
spiritNodes.map((node) => prefabSignature(prefab, node)).sort(),
sourceSpirits.map(sourceSignature).sort(),
staticSpirits.map(sourceSignature).sort(),
);
const types = prefab.reduce((counts, entry) => {
counts[entry.__type__] = (counts[entry.__type__] ?? 0) + 1;
return counts;
}, {});
assert.equal(types['cc.Sprite'], 48);
assert.equal(types['cc.Label'], 6);
for (const spirit of sourceSpirits) {
for (const spirit of staticSpirits) {
const sourceObject = objectList[spirit.id]?.Property;
assert.equal(sourceObject?.ObjectID, spirit.id, `ObjectList[${spirit.id}] must be direct indexed`);
assert.equal(sourceObject.ObjectName, spirit.name);
@@ -196,9 +197,9 @@ test('Layer418 labels preserve text, color, wrapping, and AutoSize semantics', (
const objectList = JSON.parse(fs.readFileSync(objectDataPath, 'utf8')).ObjectList;
const prefab = JSON.parse(fs.readFileSync(prefabPath, 'utf8'));
const nodes = prefab.filter((entry) => entry.__type__ === 'cc.Node');
const labels = sourceSpirits.filter((spirit) => objectList[spirit.id]?.Property.ObjectType === 4);
const labels = sourceSpirits.filter((spirit) => !dynamicSourceIds.has(spirit.id)&&objectList[spirit.id]?.Property.ObjectType === 4);
assert.equal(labels.length, 6);
assert.ok(labels.length>0);
for (const spirit of labels) {
const sourceObject = objectList[spirit.id].Property;
const node = nodes.find((candidate) => candidate._name === spirit.name);
@@ -231,3 +232,75 @@ test('Layer418 sliced sprite frames preserve their source border contract', () =
);
}
});
test('Layer418 dynamic panels own clipped scroll content, layouts and reusable rows',()=>{
const prefab=JSON.parse(fs.readFileSync(prefabPath,'utf8'));
const nodes=prefab.filter(entry=>entry.__type__==='cc.Node');
const group=nodes.find(node=>node._name==='group-30');
const child=(node,name)=>{
const matches=node._children.map(ref=>prefab[ref.__id__]).filter(item=>item._name===name);
assert.equal(matches.length,1,`${node._name}/${name} must be unique`);
return matches[0];
};
const input=child(group,'聊天输入框'),edit=nodeComponent(prefab,input,'cc.EditBox');
assert.ok(edit,'input must be a real EditBox');
for(const field of ['_textLabel','_placeholderLabel']){
const label=prefab[edit[field]?.__id__];
assert.equal(label?.__type__,'cc.Label',`${field} must resolve to a Label`);
assert.equal(prefab[prefab[label.node.__id__]._parent.__id__],input);
}
const cost=child(group,'Spirit3117'),costLabel=nodeComponent(prefab,cost,'cc.Label');
assert.equal(cost._active,false,'announcement cost is hidden until policy selection');
assert.equal(costLabel._fontSize,16,'preserve authored cost-label font size');
assert.equal(costLabel._overflow,1,'cost uses a bounded line');
assert.equal(costLabel._string,'','cost is populated from remote policy');
assert.equal(nodeComponent(prefab,cost,'cc.UITransform')._contentSize.width,520);
for(const name of ['Phrases','History','Expressions','Announcements']){
const panel=child(group,name),viewport=child(panel,'Viewport'),content=child(viewport,'Content');
assert.equal(panel._active,name==='Phrases',`${name} initial tab state`);
const scroll=nodeComponent(prefab,panel,'cc.ScrollView');
assert.ok(scroll,`${name} ScrollView`);
assert.equal(prefab[scroll._content.__id__],content,`${name} content reference`);
assert.equal(scroll.horizontal,false);assert.equal(scroll.vertical,true);
assert.ok(nodeComponent(prefab,viewport,'cc.Mask'),`${name} clipping mask`);
const layout=nodeComponent(prefab,content,'cc.Layout');
assert.ok(layout,`${name} Layout`);
assert.equal(layout._resizeMode,1,`${name} grows content container`);
assert.equal(layout._layoutType,name==='Expressions'?3:2,`${name} layout mode`);
assert.equal(nodeComponent(prefab,content,'cc.UITransform')._anchorPoint.y,1,`${name} content top anchor`);
if(name==='Phrases'){
const row=child(content,'ChatPhraseItem');
assert.ok(nodeComponent(prefab,child(row,'Text'),'cc.Label'));
assert.ok(nodeComponent(prefab,child(row,'Background'),'cc.Sprite')._spriteFrame?.__uuid__);
}else if(name==='History'){
const row=child(content,'ChatHistoryItem');
for(const label of ['Spirit164','Spirit3110','Spirit480'])assert.ok(nodeComponent(prefab,child(row,label),'cc.Label'));
for(const sprite of ['Spirit163','Spirit165','Spirit697'])assert.ok(nodeComponent(prefab,child(row,sprite),'cc.Sprite')._spriteFrame?.__uuid__);
}else if(name==='Expressions'){
assert.equal(content._children.length,20);
for(let index=1;index<=20;index++)assert.ok(nodeComponent(prefab,child(content,`Expression${index}`),'cc.Sprite')._spriteFrame?.__uuid__);
}else assert.equal(content._children.length,0,'announcements are populated from state');
}
});
test('Layer418 serialized node, component and prefab references remain intact',()=>{
const prefab=JSON.parse(fs.readFileSync(prefabPath,'utf8'));
const visit=value=>{
if(!value||typeof value!=='object')return;
if(Object.prototype.hasOwnProperty.call(value,'__id__')){
assert.ok(Number.isInteger(value.__id__)&&value.__id__>=0&&value.__id__<prefab.length,`dangling reference ${value.__id__}`);
}
Object.values(value).forEach(visit);
};
prefab.forEach(visit);
for(const [index,node] of prefab.entries()){
if(node.__type__!=='cc.Node')continue;
assert.equal(prefab[node._prefab?.__id__]?.__type__,'cc.PrefabInfo',`${node._name} prefab info`);
for(const ref of node._children)assert.equal(prefab[ref.__id__]._parent.__id__,index,`${node._name} child parent backlink`);
for(const ref of node._components){
const component=prefab[ref.__id__];
assert.equal(component.node.__id__,index,`${node._name} component backlink`);
assert.equal(prefab[component.__prefab?.__id__]?.__type__,'cc.CompPrefabInfo',`${node._name} component prefab info`);
}
}
});
@@ -0,0 +1,36 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {startPlatformLocation,callPlatformSetting} from '../../YouleNexus/assets/framework/adapters/native/platform-native.ts';
test('browser location enters the legacy callback as JSON, with a fresh payload each time',()=>{
const results:string[]=[];
startPlatformLocation({kind:'browser'},value=>results.push(value as string));
startPlatformLocation({kind:'browser'},value=>results.push(value as string));
assert.equal(results.length,2);
assert.deepEqual(JSON.parse(results[0]),{
address:'江西省南昌市青云谱区施尧路靠近上海浦东发展银行(长天支行)',city:'南昌市',cityCode:'0791',country:'中国',
district:'青云谱区',latitude:28.623546,longitude:115.900333,province:'江西省',street:'施尧路',errorCode:0,
});
assert.equal(results[0],results[1]);
});
test('settings and WVJB preserve scalar and no-argument conventions without browser callbacks',()=>{
const calls:unknown[]=[];
const settings={startlocation:(...args:unknown[])=>calls.push(['location',...args]),
stopshake:(...args:unknown[])=>calls.push(['stop',...args]),voicePlaying:(n:number)=>calls.push(['voice',n]),
SwitchShake:(n:number)=>calls.push(['sound',n])};
const host={kind:'settings' as const,settings};
startPlatformLocation(host,()=>assert.fail('native must own its callback'));
callPlatformSetting(host,'stopshake');callPlatformSetting(host,'voicePlaying',0);callPlatformSetting(host,'SwitchShake',1);
const bridge={kind:'bridge' as const,call:(name:string,data:unknown)=>calls.push([name,data])};
startPlatformLocation(bridge,()=>assert.fail('bridge must own its callback'));
callPlatformSetting(bridge,'stopshake');callPlatformSetting(bridge,'voicePlaying',1);
assert.deepEqual(calls,[['location',2],['stop'],['voice',0],['sound',1],['startlocation',2],['stopshake',''],['voicePlaying',1]]);
});
test('browser setting commands complete, but malformed native hosts and native failures stay visible',()=>{
callPlatformSetting({kind:'browser'},'voicePlaying',0);
callPlatformSetting({kind:'browser'},'stopshake');
assert.throws(()=>callPlatformSetting({kind:'settings',settings:{}},'voicePlaying',1),/voicePlaying/);
assert.throws(()=>startPlatformLocation({kind:'settings',settings:{startlocation(){throw Error('native failed');}}},()=>assert.fail()),/native failed/);
});
@@ -0,0 +1,79 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {RoomDeviceStatusAdapter,createRoomDeviceStatusSubscriptions} from '../../YouleNexus/assets/framework/adapters/native/room-device-status.ts';
import type {PlatformNativeHost} from '../../YouleNexus/assets/framework/adapters/native/platform-native.ts';
import type {NativeHandler} from '../../YouleNexus/assets/framework/adapters/native/native-contracts.ts';
function setup(host:PlatformNativeHost) {
const handlers=new Map<string,NativeHandler>();
const adapter=new RoomDeviceStatusAdapter({host,subscribe:(name,handler)=>{handlers.set(name,handler);return()=>{handlers.delete(name);};}});
return {adapter,handlers};
}
test('browser owns explicit legacy test defaults',()=>{
const {adapter}=setup({kind:'browser'});
assert.deepEqual(adapter.snapshot(),{battery:1,network:1,wifiLevel:null,wifiFrame:4,batteryFrame:2});
adapter.refresh();assert.equal(adapter.snapshot().battery,1);
});
test('settings uses exact synchronous getters and wifi callback positional arguments',()=>{
const calls:string[]=[];
const {adapter,handlers}=setup({kind:'settings',settings:{getbattery(){calls.push('getbattery');return '0.2';},getnetwork(){calls.push('getnetwork');return '2';},getwifisignalLevel(){calls.push('getwifisignalLevel');}}});
assert.deepEqual(calls,['getnetwork','getwifisignalLevel','getbattery']);
assert.equal(adapter.snapshot().batteryFrame,3);assert.equal(adapter.snapshot().wifiFrame,3);
for(const [level,frame] of [[0,1],[1,2],[2,2],[3,3],[4,3]]){
handlers.get('getwifiLevel')!('real ssid',String(level));assert.equal(adapter.snapshot().wifiFrame,frame);
}
handlers.get('getBattery')!('0.21');assert.equal(adapter.snapshot().batteryFrame,2);
handlers.get('getnetwork')!('3');assert.equal(adapter.snapshot().wifiFrame,4);
handlers.get('getnetwork')!('1');assert.equal(adapter.snapshot().wifiFrame,4);
});
test('bridge waits for callbacks without inventing native getter calls',()=>{
const {adapter,handlers}=setup({kind:'bridge',call(){assert.fail('legacy status getters never call WVJB');}});
assert.deepEqual(adapter.snapshot(),{battery:null,network:null,wifiLevel:null,wifiFrame:null,batteryFrame:null});
handlers.get('getwifiLevel')!(JSON.stringify({ssidname:'real',signalLevel:'2'}));
handlers.get('getnetwork')!('2');handlers.get('getBattery')!('0.1');
assert.equal(adapter.snapshot().wifiFrame,3);assert.equal(adapter.snapshot().batteryFrame,3);
adapter.refresh();assert.equal(adapter.snapshot().wifiFrame,3);
});
test('invalid native data and absent settings methods expose source errors',()=>{
assert.throws(()=>setup({kind:'settings',settings:{}}),/getnetwork/);
const {adapter,handlers}=setup({kind:'bridge',call(){}});
for(const value of [null,undefined,'',true,'NaN',-0.1,1.1])assert.throws(()=>handlers.get('getBattery')!(value),/battery/i);
for(const value of [0,4,'',null])assert.throws(()=>handlers.get('getnetwork')!(value),/network/);
for(const signalLevel of [-1,5,1.2,'',null])assert.throws(()=>handlers.get('getwifiLevel')!({ssidname:'x',signalLevel}),/wifi/i);
assert.equal(adapter.snapshot().battery,null);
});
test('observable status unsubscribes and late callbacks remain inert',()=>{
const {adapter,handlers}=setup({kind:'bridge',call(){}});let count=0;
const off=adapter.subscribe(()=>count++);
handlers.get('getBattery')!(1);assert.equal(count,1);off();
handlers.get('getBattery')!(0.5);assert.equal(count,1);
const retained=handlers.get('getBattery')!;adapter.dispose();retained('bad');assert.equal(handlers.size,0);
assert.throws(()=>adapter.refresh(),/disposed/);
});
test('subscriptions restore settings globals and guard retained WVJB callbacks',()=>{
const original=()=>{};const globals:Record<string,unknown>={getBattery:original};let count=0;
const subscribe=createRoomDeviceStatusSubscriptions({kind:'settings',settings:{}},globals);
const off=subscribe('getBattery',()=>count++);const retained=globals.getBattery as NativeHandler;
retained(1);off();retained(1);assert.equal(count,1);assert.equal(globals.getBattery,original);
const offNew=subscribe('getnetwork',()=>{});offNew();assert.equal('getnetwork' in globals,false);
const newer=()=>{};const offOwned=subscribe('getwifiLevel',()=>{});globals.getwifiLevel=newer;offOwned();assert.equal(globals.getwifiLevel,newer);
const handlers=new Map<string,NativeHandler>();
const offBridge=createRoomDeviceStatusSubscriptions({kind:'bridge',call(){}},{},{register:(name,handler)=>{handlers.set(name,handler);}})('getBattery',()=>count++);
handlers.get('getBattery')!(1);offBridge();handlers.get('getBattery')!(1);assert.equal(count,2);
});
test('failed settings initialization releases every installed global callback',()=>{
const original=()=>{};const globals:Record<string,unknown>={getBattery:original};
const host:PlatformNativeHost={kind:'settings',settings:{getnetwork(){return 2;}}};
assert.throws(()=>new RoomDeviceStatusAdapter({host,subscribe:createRoomDeviceStatusSubscriptions(host,globals)}),/getwifisignalLevel/);
assert.deepEqual(globals,{getBattery:original});
});
test('settings wifi callback can synchronously respond to the exact no-argument request',()=>{
const globals:Record<string,unknown>={};
const settings={getnetwork(){return 2;},getbattery(){return 0;},getwifisignalLevel(...args:unknown[]){
assert.deepEqual(args,[]);assert.equal(this,settings);(globals.getwifiLevel as NativeHandler)('ssid',0);
}};
const host:PlatformNativeHost={kind:'settings',settings};
const adapter=new RoomDeviceStatusAdapter({host,subscribe:createRoomDeviceStatusSubscriptions(host,globals)});
assert.equal(adapter.snapshot().wifiFrame,1);assert.equal(adapter.snapshot().battery,0);
adapter.dispose();assert.deepEqual(globals,{});
});
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {RoomVoiceAdapter,createRoomVoiceSubscriptions} from '../../YouleNexus/assets/framework/adapters/native/room-voice.ts';
import type {PlatformNativeHost} from '../../YouleNexus/assets/framework/adapters/native/platform-native.ts';
import type {NativeHandler} from '../../YouleNexus/assets/framework/adapters/native/native-contracts.ts';
function setup(host:PlatformNativeHost) {
const handlers=new Map<string,NativeHandler>();const sent:unknown[]=[];const played:unknown[]=[];
const adapter=new RoomVoiceAdapter({host,subscribe:(name,handler)=>{handlers.set(name,handler);return()=>{handlers.delete(name);};},
sendVoice:(url,time)=>sent.push([url,time]),onPlayback:(user,playing)=>played.push([user,playing])});
return {adapter,handlers,sent,played};
}
test('settings recording and history playback retain exact positional arguments',()=>{
const calls:unknown[]=[];
const {adapter,handlers,sent}=setup({kind:'settings',settings:{prepareaudio(...args:unknown[]){calls.push(args);},mediaTypeAudio(...args:unknown[]){calls.push(args);}}});
adapter.startRecording();adapter.stopRecording();
handlers.get('getaudiourl')!('https://audio/real.aac','3');
adapter.playVoice('https://audio/real.aac',1,10003);
assert.deepEqual(calls,[[],['https://audio/real.aac',1,10003]]);
assert.deepEqual(sent,[['https://audio/real.aac','3']]);
});
test('WVJB empty start payload, structured playback and JSON upload are unchanged',()=>{
const calls:unknown[]=[];
const {adapter,handlers,sent,played}=setup({kind:'bridge',call:(name,data)=>{calls.push([name,data]);}});
adapter.startRecording();adapter.stopRecording();
handlers.get('getaudiourl')!(JSON.stringify({audiourl:'actual.aac',time:2}));
adapter.playVoice('actual.aac',0,10001);
handlers.get('gameui_play_voice')!('10001');handlers.get('gameui_stop_voice')!(10001);
assert.deepEqual(calls,[['prepareaudio',''],['mediaTypeAudio',{audiourl:'actual.aac',type:0,user:10001}]]);
assert.deepEqual(sent,[['actual.aac',2]]);assert.deepEqual(played,[[10001,true],[10001,false]]);
});
test('browser reports unsupported without creating uploads or playback',()=>{
const {adapter,sent,played}=setup({kind:'browser'});
assert.throws(()=>adapter.startRecording(),/不支持/);assert.throws(()=>adapter.playVoice('x',0,1),/不支持/);
assert.deepEqual(sent,[]);assert.deepEqual(played,[]);
});
test('cancelled, zero duration and duplicate uploads never send; dispose removes callbacks',()=>{
const {adapter,handlers,sent}=setup({kind:'bridge',call(){}});
adapter.startRecording();adapter.cancelRecording();
assert.throws(()=>adapter.startRecording(),/pending/);
handlers.get('getaudiourl')!({audiourl:'cancelled',time:3});
adapter.startRecording();handlers.get('getaudiourl')!({audiourl:'',time:0});
adapter.startRecording();handlers.get('getaudiourl')!({audiourl:'once',time:1});
handlers.get('getaudiourl')!({audiourl:'duplicate',time:1});
assert.deepEqual(sent,[['once',1]]);
adapter.startRecording();const late=handlers.get('getaudiourl')!;adapter.dispose();late({audiourl:'late',time:3});
assert.equal(handlers.size,0);assert.equal(sent.length,1);
});
test('invalid upload source and missing native method are exposed',()=>{
const {adapter,handlers}=setup({kind:'bridge',call(){}});
adapter.startRecording();assert.throws(()=>handlers.get('getaudiourl')!({audiourl:'x'}),/time/);
assert.throws(()=>setup({kind:'settings',settings:{}}).adapter.startRecording(),/prepareaudio/);
});
test('settings subscriptions own globals and restore previous callbacks on dispose',()=>{
const original=()=>{};const globals:Record<string,unknown>={getaudiourl:original};let count=0;
const subscribe=createRoomVoiceSubscriptions({kind:'settings',settings:{}},globals);
const off=subscribe('getaudiourl',()=>{count++;});const callback=globals.getaudiourl as NativeHandler;
callback('actual',1);off();callback('late',1);
assert.equal(count,1);assert.equal(globals.getaudiourl,original);
const offNew=subscribe('gameui_play_voice',()=>{});offNew();assert.equal(Object.prototype.hasOwnProperty.call(globals,'gameui_play_voice'),false);
});
test('bridge subscriptions use legacy names, neutralize retained callbacks and preserve newer owners',()=>{
const handlers=new Map<string,NativeHandler>();let count=0;
const subscribe=createRoomVoiceSubscriptions({kind:'bridge',call(){}},{},{register:(name,handler)=>{handlers.set(name,handler);}});
const off=subscribe('gameui_play_voice',()=>{count++;});const retained=handlers.get('gameui_play_voice')!;
retained(1);off();retained(1);assert.equal(count,1);
const globals:Record<string,unknown>={};const settingsSubscribe=createRoomVoiceSubscriptions({kind:'settings',settings:{}},globals);
const release=settingsSubscribe('getaudiourl',()=>{});const newer=()=>{};globals.getaudiourl=newer;release();assert.equal(globals.getaudiourl,newer);
});
@@ -68,6 +68,25 @@ const envelope: OutboundEnvelope = {
data: { gameid: 101 },
};
test('debug packet logs preserve transmitted text and precede business dispatch',async(t)=>{
const logs:unknown[][]=[];
t.mock.method(console,'log',(...args:unknown[])=>logs.push(args));
const {client,transports}=makeHarness();client.start();await transports[0]!.flush();
const login:OutboundEnvelope={app:'youle',route:'agent',rpc:'player_login',data:{test:true}};
client.send(login);
assert.ok(logs.some(row=>row[0]==='[收发包] 发送'&&row[2]===transports[0]!.sent[0]));
const heartbeats=['{"com":"@serverheartbeat"}',JSON.stringify('{"com":"@serverheartbeat"}')];
for(const heartbeat of heartbeats)transports[0]!.serverPush(heartbeat);
await transports[0]!.flush();
assert.equal(logs.some(row=>row[0]==='[收发包] 接收'&&heartbeats.includes(row[2] as string)),false);
const raw='{"app":"youle","route":"agent","rpc":"player_login","data":{"roomtype":[0,1,0]}}';
let dispatched=false;
client.subscribe(event=>{if(event.type==='message'){
assert.ok(logs.some(row=>row[0]==='[收发包] 接收'&&row[2]===raw));dispatched=true;
}});
transports[0]!.serverPush(raw);await transports[0]!.flush();assert.ok(dispatched);client.stop();
});
test('start emits the opened server and never sends login automatically', async () => {
const { client, transports } = makeHarness();
const events: WireEvent[] = [];
@@ -10,20 +10,17 @@ async function setup() {
return { entry: api.createLoginStageGameEntry(101, event => evidence.push(event)), evidence };
}
test('real GameSessionHost validates and releases one diagnostic instance without entering a game', async () => {
test('real GameSessionHost validates the diagnostic declaration without creating a game instance', async () => {
const { entry, evidence } = await setup();
const host = new GameSessionHost(entry);
assert.equal(host.state, 'idle');
assert.equal(entry.key, 'local-platform-login');
assert.equal(entry.route, 'local-platform-login');
assert.equal(entry.gameId, 101);
assert.deepEqual(evidence, [
{ kind: 'diagnostic-module', data: { instanceId: 1, state: 'created' } },
{ kind: 'diagnostic-module', data: { instanceId: 1, state: 'disposed' } },
]);
assert.deepEqual(evidence, []);
host.dispose();
host.dispose();
assert.equal(evidence.length, 2);
assert.equal(evidence.length, 0);
});
test('every game behavior records its operation and throws; dispose is idempotent', async () => {
@@ -1,3 +1,4 @@
import { BUILD_IDENTITY } from '../fixtures/build-identity.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
@@ -28,7 +29,7 @@ test('build identity selects its own remote agent showVisitor through to login b
{gameid:'J9r4022t80pxlb01zOK76DN1WD4QewFl',channellist:[
{channelid:'aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG',marketlist:[{marketid:4,deviceLogin:true}]}]}]},
]};
const options={startupConfig:{...STARTUP_CONFIG,configSource:'remote' as const},hostKind:'h5' as const,win:{},search:'',fetcher:{fetch:async()=>config}};
const options={buildIdentity:BUILD_IDENTITY,startupConfig:{...STARTUP_CONFIG,configSource:'remote' as const},hostKind:'h5' as const,win:{},search:'',fetcher:{fetch:async()=>config}};
const resolved=await resolveRuntimeConfig(options);
assert.equal(resolved.remoteConfig.getValue('showVisitor'),1);
assert.equal(resolved.identity.channelid,'aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG');
@@ -44,7 +44,7 @@ function makeStore(location: 'outside' | 'inside', stage = 0, infinite = 0): Pla
return store;
}
function makeCommands(store: PlatformStore): {
function makeCommands(store: PlatformStore): {
readonly commands: PlatformCommands;
readonly sent: OutboundEnvelope[];
} {
@@ -57,56 +57,56 @@ function makeCommands(store: PlatformStore): {
}),
sent,
};
}
test('invite query and binding preserve exact legacy fields and leading zeros',()=>{
const store=makeStore('outside'),{commands,sent}=makeCommands(store);
commands.getPlayerInvitecode();commands.bindInvitecode('00123');
assert.deepEqual(sent,[
{app:'youle',route:'agent',rpc:'get_player_invitecode',data:{agentid:'agent-from-config',playerid:430511,unionid:'test_unionid',openid:'o1'}},
{app:'youle',route:'agent',rpc:'binding_invitecode',data:{agentid:'agent-from-config',playerid:430511,invitecode:'00123'}},
]);
assert.throws(()=>makeCommands(new PlatformStore()).commands.getPlayerInvitecode(),/logged-in/);
assert.throws(()=>makeCommands(makeStore('inside')).commands.bindInvitecode('1'),/logged-in/);
});
test('invite responses publish even unchanged codes, update only on success, and reject malformed data atomically',()=>{
const store=makeStore('outside');let updates=0;store.subscribe(()=>updates++);
store.applyInvitecode({invitecode:''});store.applyInvitecode({invitecode:''});
assert.equal(updates,2);
store.applyInviteBinding({state:0,invitecode:'00123',error:'绑定成功'});
assert.equal(store.getState().players.entities[430511].invitecode,'00123');
store.applyInviteBinding({state:1,error:'邀请码无效'});
assert.equal(store.getState().players.entities[430511].invitecode,'00123');
const before=store.getState();
assert.throws(()=>store.applyInvitecode({}),/invitecode/);
assert.throws(()=>store.applyInviteBinding({state:0,invitecode:123,error:'bad'}),/invitecode/);
assert.throws(()=>store.applyInviteBinding({state:0,invitecode:'wrong'}),/error/);
for(const state of ['',null,true,'invalid','0.5']){
assert.throws(()=>store.applyInviteBinding({state,invitecode:'wrong',error:'bad'}),/state/);
}
assert.equal(store.getState(),before);
});
}
test('VIP query and both entry forms use the exact agent protocol',()=>{
test('invite query and binding preserve exact legacy fields and leading zeros',()=>{
const store=makeStore('outside'),{commands,sent}=makeCommands(store);
commands.getPlayerInvitecode();commands.bindInvitecode('00123');
assert.deepEqual(sent,[
{app:'youle',route:'agent',rpc:'get_player_invitecode',data:{agentid:'agent-from-config',playerid:430511,unionid:'test_unionid',openid:'o1'}},
{app:'youle',route:'agent',rpc:'binding_invitecode',data:{agentid:'agent-from-config',playerid:430511,invitecode:'00123'}},
]);
assert.throws(()=>makeCommands(new PlatformStore()).commands.getPlayerInvitecode(),/logged-in/);
assert.throws(()=>makeCommands(makeStore('inside')).commands.bindInvitecode('1'),/logged-in/);
});
test('invite responses publish even unchanged codes, update only on success, and reject malformed data atomically',()=>{
const store=makeStore('outside');let updates=0;store.subscribe(()=>updates++);
store.applyInvitecode({invitecode:''});store.applyInvitecode({invitecode:''});
assert.equal(updates,2);
store.applyInviteBinding({state:0,invitecode:'00123',error:'绑定成功'});
assert.equal(store.getState().players.entities[430511].invitecode,'00123');
store.applyInviteBinding({state:1,error:'邀请码无效'});
assert.equal(store.getState().players.entities[430511].invitecode,'00123');
const before=store.getState();
assert.throws(()=>store.applyInvitecode({}),/invitecode/);
assert.throws(()=>store.applyInviteBinding({state:0,invitecode:123,error:'bad'}),/invitecode/);
assert.throws(()=>store.applyInviteBinding({state:0,invitecode:'wrong'}),/error/);
for(const state of ['',null,true,'invalid','0.5']){
assert.throws(()=>store.applyInviteBinding({state,invitecode:'wrong',error:'bad'}),/state/);
}
assert.equal(store.getState(),before);
});
test('VIP query and both entry forms use the exact agent protocol',()=>{
const store=makeStore('outside'),{commands,sent}=makeCommands(store);
commands.getVipRooms(['123456']);commands.joinVipRoom('123456',null);commands.joinVipRoom(900001,{city:'测试'});
assert.deepEqual(sent[0],{app:'youle',route:'agent',rpc:'getInfoByShortCode',data:{agentid:'agent-from-config',gameid:'game-from-config',shortcodeList:['123456']}});
assert.deepEqual(sent[1],{app:'youle',route:'agent',rpc:'self_join_room',data:{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config',roomcode:'123456',joinmode:0,location:null,ip:store.getState().players.entities[430511].ip}});
assert.equal((sent[2].data as {roomcode:number}).roomcode,900001);
assert.throws(()=>makeCommands(new PlatformStore()).commands.getVipRooms([]),/logged-in/);
});
test('ranking and redemption use exact legacy fields, preserving card text',()=>{
const {commands,sent}=makeCommands(makeStore('outside'));
commands.getTreasureList();commands.redeemCard('00Ab-12');
assert.deepEqual(sent,[
{app:'youle',route:'agent',rpc:'get_treasurelist',data:{agentid:'agent-from-config',gameid:'game-from-config'}},
{app:'youle',route:'agent',rpc:'topup_card',data:{agentid:'agent-from-config',playerid:430511,cardno:'00Ab-12'}},
]);
assert.throws(()=>commands.redeemCard(' '),/cardno/);
assert.throws(()=>makeCommands(new PlatformStore()).commands.getTreasureList(),/logged-in/);
});
});
test('ranking and redemption use exact legacy fields, preserving card text',()=>{
const {commands,sent}=makeCommands(makeStore('outside'));
commands.getTreasureList();commands.redeemCard('00Ab-12');
assert.deepEqual(sent,[
{app:'youle',route:'agent',rpc:'get_treasurelist',data:{agentid:'agent-from-config',gameid:'game-from-config'}},
{app:'youle',route:'agent',rpc:'topup_card',data:{agentid:'agent-from-config',playerid:430511,cardno:'00Ab-12'}},
]);
assert.throws(()=>commands.redeemCard(' '),/cardno/);
assert.throws(()=>makeCommands(new PlatformStore()).commands.getTreasureList(),/logged-in/);
});
test('task requests preserve legacy identity fields and reject unclaimable entries',()=>{
const store=makeStore('outside');const {commands,sent}=makeCommands(store);
@@ -261,23 +261,75 @@ test('command preconditions reject missing authentication, wrong room location,
assert.equal(inside.sent.length, 0);
assert.equal(battle.sent.length, 0);
});
test('VIP rank and owned room requests preserve legacy exact fields',()=>{
const {commands,sent}=makeCommands(makeStore('outside'));
commands.getVipRankList(10);commands.joinOwnedRoom(123456,{city:'x'});
assert.deepEqual(sent,[
{app:'youle',route:'agent',rpc:'getVipRankList',data:{agentid:'agent-from-config',limit:10}},
{app:'youle',route:'agent',rpc:'self_join_room',data:{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config',roomcode:123456,joinmode:1,location:{city:'x'},ip:'127.0.0.1'}}]);
assert.throws(()=>commands.getVipRankList(-1));
});
test('create command emits exact legacy packet and numeric keypad join stays numeric', () => {
const {commands,sent}=makeCommands(makeStore('outside'));
const roomtype=[1,4,[7,8],{option:true}];
commands.createRoom({roomtype,ip:'127.0.0.8',location:null});
commands.joinRoom({roomcode:12345,ip:'127.0.0.8',location:null});
assert.deepEqual(sent[0],{app:'youle',route:'agent',rpc:'create_room',data:{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config',roomtype,ip:'127.0.0.8',location:null}});
assert.deepEqual(sent[1].data,{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config',roomcode:12345,ip:'127.0.0.8',location:null});
assert.throws(()=>commands.createRoom({roomtype:[undefined],ip:'x',location:null}),/JSON/);
assert.throws(()=>makeCommands(makeStore('inside')).commands.createRoom({roomtype,ip:'x',location:null}),/outside|lobby/);
test('VIP rank and owned room requests preserve legacy exact fields',()=>{
const {commands,sent}=makeCommands(makeStore('outside'));
commands.getVipRankList(10);commands.joinOwnedRoom(123456,{city:'x'});
assert.deepEqual(sent,[
{app:'youle',route:'agent',rpc:'getVipRankList',data:{agentid:'agent-from-config',limit:10}},
{app:'youle',route:'agent',rpc:'self_join_room',data:{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config',roomcode:123456,joinmode:1,location:{city:'x'},ip:'127.0.0.1'}}]);
assert.throws(()=>commands.getVipRankList(-1));
});
test('create command emits exact legacy packet and numeric keypad join stays numeric', () => {
const {commands,sent}=makeCommands(makeStore('outside'));
const roomtype=[1,4,[7,8],{option:true}];
commands.createRoom({roomtype,ip:'127.0.0.8',location:null});
commands.joinRoom({roomcode:12345,ip:'127.0.0.8',location:null});
assert.deepEqual(sent[0],{app:'youle',route:'agent',rpc:'create_room',data:{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config',roomtype,ip:'127.0.0.8',location:null}});
assert.deepEqual(sent[1].data,{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config',roomcode:12345,ip:'127.0.0.8',location:null});
assert.throws(()=>commands.createRoom({roomtype:[undefined] as any,ip:'x',location:null}),/JSON/);
assert.throws(()=>makeCommands(makeStore('inside')).commands.createRoom({roomtype,ip:'x',location:null}),/outside|lobby/);
});
test('dissolution commands emit exact legacy room envelopes and await server state',()=>{
const store=makeStore('inside',1);const data=fixture('player-login-room.json').data;const players=data.players as Record<string,unknown>[];players[0]={...players[1],playerid:999,nickname:'发起者'};store.applyLoginSuccess(parseLoginResponse(data));const {commands,sent}=makeCommands(store);
commands.applyDissolution();assert.equal(store.getState().dissolution,undefined);
store.applyDissolution('other_apply_free_room',{seat:0,agreefree:{state:[0,-1,null,null],countdown:60}});
commands.agreeDissolution();commands.refuseDissolution();
assert.deepEqual(sent.map(packet=>packet.rpc),['self_apply_free_room','self_agree_free_room','self_refuse_free_room']);
for(const packet of sent)assert.deepEqual(packet,{app:'youle',route:'room',rpc:packet.rpc,data:{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001'}});
assert.equal(store.getState().dissolution?.participants.find(p=>p.seat===1)?.state,-1);
});
test('dissolution commands reject pregame, already voted and duplicate active application',()=>{
const first=makeCommands(makeStore('inside',0));assert.throws(()=>first.commands.applyDissolution(),/stage/);
const store=makeStore('inside',1),{commands,sent}=makeCommands(store);
store.applyDissolution('self_apply_free_room',{agreefree:{state:[null,0,null,null],countdown:1}});
assert.throws(()=>commands.applyDissolution(),/vote/);assert.throws(()=>commands.agreeDissolution(),/waiting/);assert.throws(()=>commands.refuseDissolution(),/waiting/);assert.equal(sent.length,0);
});
test('room chat and phrase commands preserve exact legacy envelopes and no speculative expression rpc',()=>{
const store=makeStore('inside'),{commands,sent}=makeCommands(store);
commands.sendRoomText('hello');commands.sendRoomText('notice',true);commands.sendRoomPhrase('phrase');commands.sendRoomVoice('https://audio',1.25);commands.sendRoomExpression(20);commands.changeRoom();
assert.deepEqual(sent.map(e=>[e.route,e.rpc,e.data]),[
['room','send_text',{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001',text:'hello',type:0}],
['room','send_text',{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001',text:'notice',type:1}],
['room','send_text',{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001',text:'phrase'}],
['room','send_voice',{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001',voiceurl:'https://audio',time:1.25}],
['room','send_phiz',{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001',text:20}],
['room','change_room',{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001'}],
]);
assert.throws(()=>commands.sendRoomExpression(0));assert.throws(()=>commands.sendRoomVoice('',1));
});
test('break room is allowed only for pregame owner and waits for authoritative response',()=>{
const store=makeStore('inside',0),{commands,sent}=makeCommands(store);const before=store.getState();commands.breakRoom();assert.equal(sent[0].rpc,'self_break_room');assert.equal(store.getState(),before);
assert.throws(()=>makeCommands(makeStore('inside',1)).commands.breakRoom());
});
test('voice original string duration and surrender count are preserved on wire',()=>{
const {commands,sent}=makeCommands(makeStore('inside'));commands.sendRoomVoice('url','1.2');commands.surrenderRoom(12.5);
assert.deepEqual(sent[0].data,{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001',voiceurl:'url',time:'1.2'});assert.equal(sent[1].rpc,'beanroom_surrender');assert.deepEqual(sent[1].data,{agentid:'agent-from-config',gameid:'game-from-config',playerid:430511,roomcode:'100001',count:12.5});
});
test('live game projection can restrict commands but cannot override platform rules',()=>{
const initial={revision:0,roundPhase:'waiting',selfParticipation:'waiting' as const,scoresBySeat:{},restrictions:{prepare:false as const,exit:false as const}};
let projection:import('../../YouleNexus/assets/framework/sdk/contracts/game-room-projection.ts').GameRoomProjection|null=initial;
const sent:OutboundEnvelope[]=[];const store=makeStore('inside',0);
const commands=new PlatformCommands({runtimeConfig:RUNTIME_CONFIG,store,send:{send:message=>sent.push(message)},getRoomProjection:()=>projection});
assert.throws(()=>commands.prepare(),/disallows.*prepare/);assert.throws(()=>commands.exitRoom(),/disallows.*exit/);assert.equal(sent.length,0);
projection={...initial,revision:1,restrictions:{}};commands.prepare();commands.exitRoom();assert.deepEqual(sent.map(m=>m.rpc),['player_prepare','self_exit_room']);
const battleStore=makeStore('inside',1);const battle=new PlatformCommands({runtimeConfig:RUNTIME_CONFIG,store:battleStore,send:{send:message=>sent.push(message)},getRoomProjection:()=>projection});
assert.throws(()=>battle.exitRoom(),/stage=0/);assert.equal(sent.length,2);
projection={...initial,revision:2,restrictions:{exit:false}};assert.throws(()=>battle.applyDissolution(),/disallows.*exit/);assert.equal(sent.length,2);
projection=null;battle.applyDissolution();assert.equal(sent[2].rpc,'self_apply_free_room');
});
@@ -0,0 +1,54 @@
import {readFileSync} from 'node:fs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {PlatformStore} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import {parseLoginResponse,parseOtherOfflinePayload} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
function roomData(count=4):Record<string,unknown>{
const data=JSON.parse(readFileSync(new URL('../fixtures/contracts/player-login-room.json',import.meta.url),'utf8')).data;
const self=data.players[1];
data.players=Array.from({length:count},(_,seat)=>seat===1?self:{...self,playerid:500000+seat,nickname:`玩家${seat}`,onstate:0});
return data;
}
function setup(count=4){const store=new PlatformStore();store.applyLoginSuccess(parseLoginResponse(roomData(count)));return store;}
test('apply snapshots dynamic participants and deadline; all agree remains active pending free_room',()=>{
const store=setup(12);store.applyDissolution('other_apply_free_room',{seat:0,agreefree:{state:[0,...Array(11).fill(-1)],countdown:60}},1000);
const vote=store.getState().dissolution;assert.ok(vote?.kind==='active');assert.equal(vote.participants.length,12);assert.equal(vote?.initiator?.nickname,'玩家0');assert.equal(vote?.deadline,61000);
for(let seat=1;seat<12;seat++)store.applyDissolution('other_agree_free_room',{seat},5000);
assert.equal(store.getState().dissolution?.kind,'active');
store.playerOffline(parseOtherOfflinePayload({seat:0}));assert.equal(store.getState().dissolution?.kind,'active');
});
test('refusal result keeps participant snapshots and acknowledgement only clears vote',()=>{
const store=setup();store.applyDissolution('self_apply_free_room',{agreefree:{state:[-1,0,-1,-1],countdown:20}},0);
const room=store.getState().room;
store.applyDissolution('other_refuse_free_room',{seat:2},1);
const vote=store.getState().dissolution;assert.ok(vote?.kind==='result');assert.equal(vote.passed,false);assert.equal(vote.refusedSeat,2);
store.acknowledgeDissolution();assert.equal(store.getState().dissolution,null);assert.equal(store.getState().room,room);
});
test('server result interprets seats as states and writes zero roomcard, preserving deskfree',()=>{
const store=setup();store.applyDissolution('free_room',{seats:[0,1,-1,1],roomcard:0,deskfree:{rounds:[1]}},0);
const vote=store.getState().dissolution;assert.ok(vote?.kind==='result');assert.equal(vote.passed,true);assert.equal(vote?.initiator?.seat,0);assert.deepEqual(vote?.participants.map(p=>p.state),[0,1,-1,1]);assert.deepEqual(vote?.deskfree,{rounds:[1]});assert.equal(store.getState().players.entities[430511].roomcard,0);
});
test('reconnect restores vote atomically from agreefree; malformed source never commits',()=>{
const store=setup();const data=roomData();data.agreefree={state:[-1,1,0,-1],countdown:23};
let commits=0;store.subscribe(()=>commits++);const before=Date.now();store.applyLoginSuccess(parseLoginResponse(data));
const vote=store.getState().dissolution;assert.ok(vote?.kind==='active');assert.equal(commits,1);assert.equal(vote.initiator.seat,2);assert.ok(vote.deadline>=before+23000);
const state=store.getState();assert.throws(()=>store.applyDissolution('other_apply_free_room',{seat:0,agreefree:{state:[0,0,-1,-1],countdown:3}},0),/initiator/);assert.equal(store.getState(),state);
});
test('freeNow allows no seats and retains server tips without inventing countdown',()=>{
const store=setup();store.applyDissolution('free_room',{freeNow:1,tips:'强制解散',deskfree:{round:1}},0);
const vote=store.getState().dissolution;assert.ok(vote?.kind==='result');assert.equal(vote.freeNow,true);assert.equal(vote.initiator,null);assert.equal(vote.tips,'强制解散');
});
test('sparse room vote state preserves absolute seats and rejects malformed source atomically',()=>{
const data=roomData();(data.players as unknown[])[2]=null;const store=new PlatformStore();store.applyLoginSuccess(parseLoginResponse(data));
store.applyDissolution('other_apply_free_room',{seat:0,agreefree:{state:[0,-1,null,1],countdown:0}},99);
const vote=store.getState().dissolution;assert.deepEqual(vote?.participants.map(p=>p.seat),[0,1,3]);
const before=store.getState();for(const state of [[0,-1,-1,1],[0,-1],[0,-1,null,9]])assert.throws(()=>store.applyDissolution('other_apply_free_room',{seat:0,agreefree:{state,countdown:10}},0));
assert.throws(()=>store.applyDissolution('free_room',{seats:[0,1,null,1],roomcard:'bad'},0));assert.equal(store.getState(),before);
});
test('self vote is acknowledged by the server and a fresh login clears stale vote',()=>{
const store=setup();store.applyDissolution('other_apply_free_room',{seat:0,agreefree:{state:[0,-1,-1,-1],countdown:5}},0);
store.applyDissolution('self_agree_free_room',{},1);assert.equal(store.getState().dissolution?.participants[1].state,1);
store.applyLoginSuccess(parseLoginResponse(roomData()));assert.equal(store.getState().dissolution,undefined);
});
@@ -71,7 +71,7 @@ function entry(route = 'fixture-route'): GameEntry {
key: 'fixture',
gameId: 'game-41',
route,
resolveSeatCount: (roomtype) => roomtype[0] as number,
resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;},
createModule: () => ({
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
}),
@@ -81,6 +81,8 @@ function entry(route = 'fixture-route'): GameEntry {
function makeLease(options: {
readonly store?: PlatformStore;
readonly gameEntry?: GameEntry;
readonly feedback?:import('../../YouleNexus/assets/framework/sdk/contracts/game-capabilities.ts').FeedbackOwnerPort;
readonly numericTools?: import('../../YouleNexus/assets/framework/platform/game-host-adapter.ts').NumericToolsPort;
} = {}) {
const store = options.store ?? roomStore();
const sent: OutboundEnvelope[] = [];
@@ -91,10 +93,39 @@ function makeLease(options: {
store,
commands,
send,
numericTools: options.numericTools,
feedback:options.feedback,
});
return { ...lease, store, sent };
}
test('numeric UI capabilities preserve values and revoke callbacks when their room lease ends',()=>{
let input:((value:number|string)=>void)|undefined;
let calculator:((values:readonly number[])=>void)|undefined;
const results:unknown[]=[];let closes=0;let hides=0;
const tools={openNumberInput(request){assert.equal(request.message,'ID');input=request.onConfirm;},
showMultiplier(request){assert.deepEqual(request.values,[0.1,-2]);calculator=request.onConfirm;},
hideMultiplier(){hides++;},closeNumberInput(){},close(){closes++;}} satisfies import('../../YouleNexus/assets/framework/platform/game-host-adapter.ts').NumericToolsPort;
const lease=makeLease({numericTools:tools});
lease.host.execute({type:'ui.open-number-input',message:'ID',begin0:true,onConfirm:value=>results.push(value)});
lease.host.execute({type:'ui.show-multiplier',values:[0.1,-2],onConfirm:value=>results.push(value)});
input!('001');calculator!([0.02,-0.4]);assert.deepEqual(results,['001',[0.02,-0.4]]);
lease.host.execute({type:'ui.hide-multiplier'});assert.equal(hides,1);
lease.invalidate();lease.invalidate();assert.equal(closes,1);
input!('old');calculator!([9]);assert.equal(results.length,2);
assert.throws(()=>lease.host.execute({type:'ui.hide-multiplier'}),GameSessionDisposedError);
});
test('numeric UI replacement and explicit close discard old callbacks within the same room',()=>{
const callbacks:((value:number|string)=>void)[]=[];let closes=0;const results:unknown[]=[];
const lease=makeLease({numericTools:{openNumberInput:request=>{callbacks.push(request.onConfirm);},
closeNumberInput:()=>{closes++;},showMultiplier:()=>{},hideMultiplier:()=>{},close:()=>{}}});
for(let index=0;index<2;index++)lease.host.execute({type:'ui.open-number-input',message:'ID',onConfirm:value=>results.push(value)});
callbacks[0]('stale');callbacks[1]('current');assert.deepEqual(results,['current']);
lease.host.execute({type:'ui.close-number-input'});callbacks[1]('closed');
assert.equal(closes,1);assert.deepEqual(results,['current']);
});
test('sendGameMessage binds app and GameEntry route, rejects empty rpc, and preserves data identity', () => {
const { host, sent } = makeLease();
const data = { card: 7 };
@@ -188,6 +219,7 @@ test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roo
assert.equal(Object.isFrozen(snapshot.self), true);
assert.equal(Object.isFrozen(snapshot.room), true);
assert.equal(Object.isFrozen(snapshot.room.roomtype), true);
assert.ok(Array.isArray(snapshot.room.roomtype));
assert.equal(Object.isFrozen(snapshot.room.roomtype[1]), true);
assert.equal(Object.isFrozen((snapshot.room.roomtype[1] as { nested: unknown[] }).nested), true);
assert.equal(Object.isFrozen(snapshot.seats), true);
@@ -206,7 +238,7 @@ test('RuntimeSession room replacement keeps the old lease isolated until release
const staleDisposeResults: unknown[] = [];
const gameEntry: GameEntry = {
...entry(),
resolveSeatCount: (roomtype) => roomtype[0] as number,
resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;},
createModule: () => {
let host: GameHost | null = null;
return {
@@ -505,3 +537,61 @@ test('invalidate is idempotent, old unsubscribe stays safe, and every stale Host
assert.equal(callbacks, 1);
assert.equal(sent.length, 0);
});
test('game host exposes dissolution application through exact room command',()=>{
const {host,sent}=makeLease({store:roomStore(4,1,1)});host.execute({type:'room.apply-dissolution'});
assert.deepEqual(sent,[{app:'youle',route:'room',rpc:'self_apply_free_room',data:{agentid:17,gameid:'game-41',playerid:430511,roomcode:'100001'}}]);
});
test('grouped input results distinguish types, cancellation, overlap and lease cleanup',async()=>{
let input!:Parameters<import('../../YouleNexus/assets/framework/sdk/contracts/game-capabilities.ts').NumericToolsPort['openNumberInput']>[0];
let multiplier!:Parameters<import('../../YouleNexus/assets/framework/sdk/contracts/game-capabilities.ts').NumericToolsPort['showMultiplier']>[0];
const tools={openNumberInput(request:typeof input){input=request;},showMultiplier(request:typeof multiplier){multiplier=request;},closeNumberInput(){input?.onCancel?.();},hideMultiplier(){multiplier?.onCancel?.();},close(){throw Error('close failed');}};
const lease=makeLease({numericTools:tools});const caps=lease.host.capabilities!;
const number=caps.ui.requestNumber({message:'N'});await assert.rejects(caps.ui.requestDigitText({message:'overlap'}),/pending/);
input.onConfirm(12);assert.deepEqual(await number,{kind:'confirmed',value:12});
const text=caps.ui.requestDigitText({message:'ID'});input.onConfirm('001');assert.deepEqual(await text,{kind:'confirmed',value:'001'});
const cancelled=caps.ui.requestNumber({message:'Cancel'});input.onCancel!();assert.deepEqual(await cancelled,{kind:'cancelled',reason:'user'});
const replaced=caps.ui.requestNumber({message:'Replace'});const late=input.onConfirm;
lease.host.execute({type:'ui.open-number-input',message:'Legacy',onConfirm(){}});assert.deepEqual(await replaced,{kind:'cancelled',reason:'replaced'});late(99);
const pending=caps.ui.requestMultiplier({values:[1,2]});const cleanup:string[]=[];
caps.scope.defer(()=>{cleanup.push('first');});caps.scope.defer(()=>{cleanup.push('second');throw Error('cleanup failed');});
assert.throws(()=>lease.invalidate());assert.deepEqual(cleanup,['second','first']);assert.equal(caps.scope.active,false);
assert.deepEqual(await pending,{kind:'cancelled',reason:'scope-ended'});multiplier.onConfirm([9]);
assert.throws(()=>caps.messages.send('play',{}),GameSessionDisposedError);assert.throws(()=>caps.scope.defer(()=>{}),GameSessionDisposedError);
await assert.rejects(caps.ui.requestNumber({message:'stale'}),GameSessionDisposedError);lease.invalidate();
});
test('typed inputs reject invalid adapter results without leaving a request pending',async()=>{
let confirm!:(value:number|string)=>void;const lease=makeLease({numericTools:{openNumberInput(r){confirm=r.onConfirm;},closeNumberInput(){},showMultiplier(){},hideMultiplier(){},close(){}}});
const first=lease.host.capabilities!.ui.requestNumber({message:'N'});confirm('wrong');await assert.rejects(first,/type/);
const next=lease.host.capabilities!.ui.requestNumber({message:'N'});confirm(3);assert.deepEqual(await next,{kind:'confirmed',value:3});lease.invalidate();
});
test('grouped commands bind routing and scope while explicit exit uses central policy',()=>{
const {host,sent}=makeLease({store:roomStore(4,1,1)});const caps=host.capabilities!;
assert.equal(caps.scope.active,true);caps.room.requestExit();assert.equal(sent[0].rpc,'self_apply_free_room');
caps.messages.send('card',{value:1});assert.equal(sent[1].route,'fixture-route');
assert.throws(()=>caps.messages.send('card',{value:undefined} as any),/JSON/);
});
test('pending input closes its partial view and preserves both opening and cleanup failures',async()=>{
const openError=Error('open failed'),closeError=Error('close failed');let closes=0;
const lease=makeLease({numericTools:{openNumberInput(){throw openError;},closeNumberInput(){closes++;throw closeError;},showMultiplier(){},hideMultiplier(){},close(){}}});
await assert.rejects(lease.host.capabilities!.ui.requestNumber({message:'N'}),error=>{
assert.deepEqual((error as Error&{errors:unknown[]}).errors,[openError,closeError]);return true;
});
assert.equal(closes,1);lease.invalidate();
});
test('late grouped input confirmation after room replacement resolves scope cancellation',async()=>{
let confirm!:(value:number|string)=>void;const lease=makeLease({numericTools:{openNumberInput(r){confirm=r.onConfirm;},closeNumberInput(){},showMultiplier(){},hideMultiplier(){},close(){}}});
const result=lease.host.capabilities!.ui.requestNumber({message:'N'});
lease.store.clearRoom();confirm(2);assert.deepEqual(await result,{kind:'cancelled',reason:'scope-ended'});lease.invalidate();
});
test('owned UI handles are automatically released and cannot close another source',()=>{
const closed:string[]=[];let count=0;
const lease=makeLease({feedback:{beginLoading(){const id='load'+(++count);return {close(){closed.push(id);}};},showBusinessTip(){return {close(){closed.push('tip');}};}}});
const a=lease.host.capabilities!.ui.beginLoading(),b=lease.host.capabilities!.ui.beginLoading();
lease.host.capabilities!.ui.showBusinessTip('操作成功',1000);a.close();a.close();assert.deepEqual(closed,['load1']);
lease.invalidate();assert.deepEqual(closed,['load1','tip','load2']);b.close();assert.equal(closed.length,3);
assert.throws(()=>lease.host.capabilities!.ui.beginLoading(),GameSessionDisposedError);
});
@@ -37,8 +37,8 @@ test('applyLoginSuccess commits the complete login once and notifies once', () =
assert.equal(state.players.selfPlayerId, 430511);
assert.deepEqual(state.players.entities[430511], {
playerid: 430511,
nickname: '测试号',
openid:'o1',unionid:'test_unionid',invitecode:null,
nickname: '测试号',
openid:'o1',unionid:'test_unionid',invitecode:null,
avatar: 'http://a',
sex: 0,
bean: 0,
@@ -73,7 +73,7 @@ test('room login stores one canonical player entity and recursively freezes room
assert.deepEqual(room.roomtype, expectedRoomtype);
assert.equal(Object.isFrozen(room.roomtype), true);
assert.equal(Object.isFrozen(room.roomtype[5]), true);
assert.equal(Object.isFrozen((room.roomtype[5] as unknown[])[2]), true);
assert.equal(Object.isFrozen(((room.roomtype as readonly unknown[])[5] as unknown[])[2]), true);
assert.equal(Object.isFrozen(sourceRoomtype), false);
assert.equal(Object.isFrozen(sourceRoomtype[5]), false);
assert.equal(Object.isFrozen((sourceRoomtype[5] as unknown[])[2]), false);
@@ -81,7 +81,7 @@ test('room login stores one canonical player entity and recursively freezes room
assert.equal('deskinfo' in state, false);
assert.equal('deskinfo' in state.room, false);
assert.throws(() => {
(room.roomtype[5] as unknown[])[0] = 99;
((room.roomtype as readonly unknown[])[5] as unknown[])[0] = 99;
}, TypeError);
});
@@ -643,14 +643,14 @@ test('roomtype accessors are rejected without invocation or input mutation', ()
const roomtype = [child, throwing];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const parse = () => parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype.*boom.*accessor/i);
assert.throws(() => store.replaceRoom(parse()), TypeError);
assert.equal(getterCalls, 0);
assert.equal(Object.isFrozen(child), false);
assert.equal(Object.isFrozen(throwing), false);
@@ -714,14 +714,14 @@ test('dynamic roomtype accessors cannot leak mutable children into canonical sta
const roomtype = [dynamic];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const parse = () => parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype.*child.*accessor/i);
assert.throws(() => store.replaceRoom(parse()), TypeError);
assert.equal(getterCalls, 0);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(dynamic), false);
@@ -737,14 +737,14 @@ test('cyclic roomtype input is rejected atomically without mutating caller data'
const roomtype = [{ value: 1 }, cyclic];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const parse = () => parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype\[1\]\[0\].*cyclic/i);
assert.throws(() => store.replaceRoom(parse()), TypeError);
assert.equal(Object.isFrozen(roomtype), false);
assert.equal(Object.isFrozen(roomtype[0]), false);
assert.equal(Object.isFrozen(cyclic), false);
@@ -796,14 +796,14 @@ test('roomtype own-key changes between copy and verification fail atomically', (
const roomtype = [unstable];
const data = fixture('self-join-room.json').data;
data.roomtype = roomtype;
const parsed = parseSelfJoinRoomResponse(data);
const parse = () => parseSelfJoinRoomResponse(data);
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json').data));
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.replaceRoom(parsed), /roomtype\[0\].*unstable.*own keys/i);
assert.throws(() => store.replaceRoom(parse()), TypeError);
assert.equal(ownKeyReads >= 2, true);
assert.deepEqual(target, { first: 1, second: 2 });
assert.equal(Object.isFrozen(roomtype), false);
@@ -812,29 +812,8 @@ test('roomtype own-key changes between copy and verification fail atomically', (
assert.equal(notifications, 0);
});
test('canonical roomtype preserves sparse length, holes, and negative zero', () => {
const sparse = new Array<unknown>(5);
sparse[1] = -0;
sparse[4] = { tail: true };
const data = fixture('player-login-room.json').data;
data.roomtype = sparse;
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(data));
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
assert.ok(Array.isArray(state.room.roomtype));
assert.equal(state.room.roomtype.length, 5);
assert.equal(0 in state.room.roomtype, false);
assert.equal(1 in state.room.roomtype, true);
assert.equal(Object.is(state.room.roomtype[1], -0), true);
assert.equal(2 in state.room.roomtype, false);
assert.equal(3 in state.room.roomtype, false);
assert.deepEqual(state.room.roomtype[4], { tail: true });
assert.equal(Object.isFrozen(state.room.roomtype[4]), true);
assert.equal(Object.isFrozen(sparse), false);
assert.equal(Object.isFrozen(sparse[4]), false);
test('sparse roomtype is rejected instead of silently changing holes to null',()=>{
const data=fixture('player-login-room.json').data;data.roomtype=new Array(5);assert.throws(()=>parseLoginResponse(data),/sparse/);
});
test('canonical roomtype keeps own __proto__ data without prototype pollution', () => {
@@ -853,7 +832,7 @@ test('canonical roomtype keeps own __proto__ data without prototype pollution',
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
const canonical = state.room.roomtype[0] as Record<string, unknown>;
const canonical = (state.room.roomtype as readonly unknown[])[0] as Record<string, unknown>;
assert.equal(Object.getPrototypeOf(canonical), Object.prototype);
assert.equal(Object.prototype.hasOwnProperty.call(canonical, '__proto__'), true);
assert.deepEqual(canonical.__proto__, { polluted: true });
@@ -880,13 +859,13 @@ test('non-JSON roomtype own data is rejected without state or caller mutation',
const roomtype = makeRoomtype();
const data = fixture('player-login-room.json').data;
data.roomtype = roomtype;
const parsed = parseLoginResponse(data);
const parse = () => parseLoginResponse(data);
const store = new PlatformStore();
const before = store.getState();
let notifications = 0;
store.subscribe(() => { notifications += 1; });
assert.throws(() => store.applyLoginSuccess(parsed), new RegExp(error, 'i'));
assert.throws(() => store.applyLoginSuccess(parse()), TypeError);
assert.equal(Object.isFrozen(roomtype), false);
if (typeof roomtype[0] === 'object' && roomtype[0] !== null) {
assert.equal(Object.isFrozen(roomtype[0]), false);
@@ -0,0 +1,44 @@
import {readFileSync} from 'node:fs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {PlatformStore} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import {parseLoginResponse,parseSelfJoinRoomResponse} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
import {PlatformHandlers} from '../../YouleNexus/assets/framework/protocol/platform-handlers.ts';
import {RoomBroadcastQueue} from '../../YouleNexus/assets/framework/presentation/room-broadcast-queue.ts';
const login=()=>parseLoginResponse(JSON.parse(readFileSync(new URL('../fixtures/contracts/player-login-room.json',import.meta.url),'utf8')).data);
function setup(){const store=new PlatformStore();store.applyLoginSuccess(login());return store;}
test('marquee preserves duplicate arrival order separately from chat announcements and clears at room/login boundaries',()=>{
const store=setup();store.addRoomBroadcast('一');store.addRoomBroadcast('一');
store.applyRoomSocial('send_text',{text:'聊天公告',type:1});
assert.deepEqual(store.getState().roomBroadcasts,['一','一']);assert.ok(Object.isFrozen(store.getState().roomBroadcasts));
store.applyLoginSuccess(login());assert.equal(store.getState().roomBroadcasts,undefined);
store.addRoomBroadcast('old room');
store.replaceRoom(parseSelfJoinRoomResponse(JSON.parse(readFileSync(new URL('../fixtures/contracts/self-join-room.json',import.meta.url),'utf8')).data));
assert.equal(store.getState().roomBroadcasts,undefined);
store.addRoomBroadcast('二');store.clearRoom();assert.equal(store.getState().roomBroadcasts,undefined);
const outside=store.getState();store.addRoomBroadcast('outside');assert.equal(store.getState(),outside);
});
test('both documented broadcast routes dispatch marquee; omitted msgtype is immediate message; invalid types do not commit',()=>{
const store=setup(),immediate:string[]=[];
const handler=new PlatformHandlers({store,gameSession:{} as never,session:{} as never,connection:{} as never,showMessage:(text,time)=>{assert.equal(time,0);immediate.push(text);}});
for(const route of ['agent','room'])handler.dispatch({route,rpc:'broadcast',data:{msgtype:1,msgcontent:route}} as never);
assert.deepEqual(store.getState().roomBroadcasts,['agent','room']);
handler.dispatch({route:'agent',rpc:'broadcast',data:{msgcontent:'immediate'}} as never);assert.deepEqual(immediate,['immediate']);
const previous=store.getState();
for(const msgtype of [null,'1',2])assert.throws(()=>handler.dispatch({route:'room',rpc:'broadcast',data:{msgtype,msgcontent:'bad'}} as never));
assert.equal(store.getState(),previous);
});
test('marquee uses legacy byte length and speed, plays sequentially and stops after clear',()=>{
const queue=new RoomBroadcastQueue(100);queue.enqueue('A中^');queue.enqueue('B');
assert.deepEqual(queue.current,{text:'A中^',textWidth:90,offsetX:100,durationSeconds:1.9,elapsedSeconds:0});
queue.advance(1);assert.equal(queue.current!.offsetX,0);queue.advance(.9);
assert.equal(queue.current!.text,'B');assert.ok(Math.abs(queue.current!.offsetX-100)<1e-10);
queue.advance(1.18);assert.equal(queue.current,null);
queue.enqueue('stale');queue.enqueue('queued');queue.clear();queue.advance(100);assert.equal(queue.current,null);
queue.enqueue('new');assert.equal((queue as RoomBroadcastQueue).current!.offsetX,100);
});
test('large animation frame advances through queued messages without losing elapsed time',()=>{
const queue=new RoomBroadcastQueue(100);queue.enqueue('A');queue.enqueue('B');queue.advance(1.68);
assert.equal(queue.current!.text,'B');assert.ok(Math.abs(queue.current!.offsetX-50)<1e-10);
assert.throws(()=>queue.advance(-1));assert.throws(()=>new RoomBroadcastQueue(0));
});
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {RoomHudClock,roomHudCycles,roomHudTime} from '../../YouleNexus/assets/framework/presentation/room-hud-model.ts';
test('local clock pads both fields and cycles ordinary rooms each five seconds',()=>{
assert.deepEqual(roomHudTime(new Date(2026,0,1,3,4)),{hours:'03',minutes:'04'});
const clock=new RoomHudClock();clock.update(4,true,true);assert.equal(clock.showRoomCode,false);
clock.update(1,true,true);assert.equal(clock.showRoomCode,true);
clock.update(5,true,true);assert.equal(clock.showRoomCode,false);
});
test('hidden149 gates elapsed time and infinite/match rooms do not cycle',()=>{
const clock=new RoomHudClock();clock.update(10,false,true);assert.equal(clock.showRoomCode,false);assert.equal(clock.colonOpacity,255);
clock.update(1,true,false);assert.equal(clock.colonOpacity,100);
clock.update(9,true,false);assert.equal(clock.showRoomCode,false);assert.equal(clock.colonOpacity,255);
assert.equal(roomHudCycles({infinite:1,matchId:null}),false);
assert.equal(roomHudCycles({infinite:0,matchId:234}),false);
assert.equal(roomHudCycles({infinite:0,matchId:null}),true);
assert.equal(roomHudCycles({infinite:0,matchId:0}),true);
});
test('reset releases prior room clock state and large elapsed steps preserve parity',()=>{
const clock=new RoomHudClock();clock.update(15,true,true);assert.equal(clock.showRoomCode,true);
clock.reset();assert.equal(clock.showRoomCode,false);assert.equal(clock.colonOpacity,255);
clock.update(20,true,true);assert.equal(clock.showRoomCode,false);assert.equal(clock.colonOpacity,255);
});
@@ -0,0 +1,31 @@
import {readFileSync} from 'node:fs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {PlatformStore} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import {parseLoginResponse,parseOtherExitRoomPayload} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
function setup(){const data=JSON.parse(readFileSync(new URL('../fixtures/contracts/player-login-room.json',import.meta.url),'utf8')).data;data.players[0]={...data.players[1],playerid:777,nickname:'other'};const store=new PlatformStore();store.applyLoginSuccess(parseLoginResponse(data));return store;}
test('local message IDs remain stable across append and do not come from raw server input',()=>{
const store=setup();store.applyRoomSocial('send_text',{seat:1,text:'same',id:999});
const first=store.getState().roomSocial!.history[0];store.applyRoomSocial('send_text',{seat:1,text:'same',id:999});
const history=store.getState().roomSocial!.history;assert.equal(history[0],first);assert.notEqual(history[1].id,first.id);assert.notEqual(first.id,999);
});
test('chat retains sender snapshot after leaving, accepts raw phrase indices and announcement without seat',()=>{const store=setup();store.applyRoomSocial('send_text',{seat:0,text:'你好',type:0});const first=store.getState().roomSocial!.history[0];store.playerExit(parseOtherExitRoomPayload({seat:0}));assert.equal(store.getState().roomSocial!.history[0],first);assert.ok(Object.isFrozen(first.sender));store.applyRoomSocial('send_text',{seat:1,text:'2',type:3});assert.equal(store.getState().roomSocial!.history[1].kind,'phrase');store.applyRoomSocial('send_text',{text:'公告',type:1});assert.deepEqual(store.getState().roomSocial!.announcements,['公告']);});
test('invalid social input never commits, voice retains original duration, room exit clears history',()=>{const store=setup();store.applyRoomSocial('send_voice',{seat:1,voiceurl:'https://audio/one',time:'1.25'});assert.equal(store.getState().roomSocial!.history[0].time,1.25);const state=store.getState();assert.throws(()=>store.applyRoomSocial('send_phiz',{seat:1,text:0}));assert.equal(store.getState(),state);store.clearRoom();assert.equal(store.getState().roomSocial,undefined);});
test('change_seat swaps occupancy and own seat without altering sender history',()=>{const store=setup();store.applyRoomSocial('send_text',{seat:1,text:'a'});const before=store.getState().room;assert.ok(before.kind==='inside');store.changeSeats({seat1:1,seat2:0});const room=store.getState().room;assert.ok(room.kind==='inside');assert.equal(room.selfSeat,0);assert.equal(room.seatPlayerIds[0],before.seatPlayerIds[1]);assert.equal(store.getState().roomSocial!.history[0].sender.seat,1);});
test('ordinary voice auto-plays while robot voice remains history only',()=>{
const store=setup();
for(const type of [undefined,0,'0',2,'2'])store.applyRoomSocial('send_voice',{seat:1,voiceurl:'https://audio',time:1,...(type===undefined?{}:{type}),...(Number(type)===2?{info:{nickname:'客服'}}:{})});
assert.deepEqual(store.getState().roomSocial!.history.map(message=>message.autoPlay),[true,true,true,false,false]);
const snapshot=store.getState();assert.throws(()=>store.applyRoomSocial('send_voice',{type:1,seat:1,voiceurl:'url',time:1}));assert.equal(store.getState(),snapshot);
});
test('room owner notice comes only from ownerNotice and empty source has explicit null semantics',()=>{
const source=()=>JSON.parse(readFileSync(new URL('../fixtures/contracts/player-login-room.json',import.meta.url),'utf8')).data;
for(const value of [undefined,null,'','房主公告内容']){
const data=source();if(value!==undefined)data.ownerNotice=value;data.ownerNote='private note must not replace notice';
const store=new PlatformStore();store.applyLoginSuccess(parseLoginResponse(data));const room=store.getState().room;assert.ok(room.kind==='inside');assert.equal(room.ownerNotice,value?value:null);
}
const store=setup(),before=store.getState(),bad=source();bad.ownerNotice={text:'not string'};assert.throws(()=>store.applyLoginSuccess(parseLoginResponse(bad)));assert.equal(store.getState(),before);
});
@@ -0,0 +1,111 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {RoomCoordinator} from '../../../YouleNexus/assets/framework/platform/room/room-coordinator.ts';
import {RoomScope} from '../../../YouleNexus/assets/framework/platform/room/room-scope.ts';
function deferred<T>(){let resolve!:(value:T)=>void;let reject!:(reason:unknown)=>void;const promise=new Promise<T>((yes,no)=>{resolve=yes;reject=no;});return {promise,resolve,reject};}
class Clock {
callbacks=new Map<number,()=>void>();next=0;
setTimeout(callback:()=>void,_milliseconds:number):unknown{const id=++this.next;this.callbacks.set(id,callback);return id;}
clearTimeout(handle:unknown):void{this.callbacks.delete(handle as number);}
fire():void{const callbacks=[...this.callbacks.values()];this.callbacks.clear();callbacks.forEach(callback=>callback());}
}
function fixture(capacity=3){const clock=new Clock(),failures:unknown[]=[];const coordinator=new RoomCoordinator<()=>void>({capacity,maxWaitMs:500,clock,onFailure:failure=>failures.push(failure)});return {clock,failures,coordinator};}
const settle=async()=>{await Promise.resolve();await Promise.resolve();};
test('scope invalidates before reverse cleanup and attempts every disposer once',()=>{
const order:string[]=[],first=Error('first'),second=Error('second');
const scope=new RoomScope(1,()=>{order.push('invalidate');throw first;});
scope.own(()=>order.push('a'));scope.own(()=>{order.push('b');throw second;});
assert.deepEqual(scope.dispose(),[first,second]);assert.equal(scope.active,false);
assert.deepEqual(order,['invalidate','b','a']);assert.deepEqual(scope.dispose(),[]);
scope.own(()=>order.push('late'));assert.equal(order[order.length-1],'late');assert.equal(scope.resourceCount,0);
});
test('mount gates ready and FIFO; reentrant message stays after already queued inputs',async()=>{
const {coordinator,clock,failures}=fixture(),mount=deferred<void>(),events:string[]=[];
coordinator.enter({invalidate(){},mount:()=>mount.promise,ready:()=>events.push('ready'),handle:message=>message()});
coordinator.dispatch(()=>{events.push('one');coordinator.dispatch(()=>events.push('three'));});
coordinator.dispatch(()=>events.push('two'));
assert.deepEqual(events,[]);assert.equal(coordinator.state,'entering');assert.equal(clock.callbacks.size,1);
mount.resolve();await settle();
assert.deepEqual(events,['ready','one','two','three']);assert.equal(coordinator.state,'active');assert.equal(clock.callbacks.size,0);assert.deepEqual(failures,[]);
});
test('cancel/kick invalidates immediately and late mount only releases its own resource',async()=>{
const {coordinator,clock,failures}=fixture(),oldMount=deferred<()=>void>(),events:string[]=[];
const old=coordinator.enter({invalidate:()=>events.push('old-invalid'),mount:()=>oldMount.promise,ready:()=>events.push('old-ready'),handle:message=>message()});
coordinator.dispatch(()=>events.push('old-message'));coordinator.cancel();
const current=coordinator.enter({invalidate:()=>events.push('new-invalid'),mount:()=>()=>events.push('new-clean'),ready:()=>events.push('new-ready'),handle:message=>message()});
oldMount.resolve(()=>events.push('old-clean'));await settle();
assert.deepEqual(events,['old-invalid','new-ready','old-clean']);assert.equal(old.active,false);assert.equal(current.active,true);assert.equal(clock.callbacks.size,0);assert.deepEqual(failures,[]);
});
test('overflow and queue timeout fail the session with queue and timer fully released',()=>{
for(const cause of ['overflow','timeout']){
const {coordinator,clock,failures}=fixture(1),mount=deferred<void>();let invalidations=0;
const scope=coordinator.enter({invalidate:()=>invalidations++,mount:()=>mount.promise,ready(){},handle:message=>message()});
coordinator.dispatch(()=>{});
if(cause==='overflow')coordinator.dispatch(()=>{});else clock.fire();
assert.equal(coordinator.state,'failed');assert.equal(scope.active,false);assert.equal(invalidations,1);assert.equal(clock.callbacks.size,0);assert.equal(failures.length,1);
assert.match(String(coordinator.failure?.errors[0]),cause==='overflow'?/capacity/:/timed out/);
}
});
test('mount and handler failures aggregate cleanup errors and report once',async()=>{
for(const stage of ['mount','ready','handle']){
const {coordinator,failures}=fixture(),failure=Error(stage),cleanup=Error('cleanup'),mount=deferred<void>();
coordinator.enter({invalidate:()=>{throw cleanup;},mount:()=>mount.promise,ready:()=>{if(stage==='ready')throw failure;},handle:()=>{throw failure;}});
if(stage==='mount')mount.reject(failure);else {coordinator.dispatch(()=>{});mount.resolve();}
await settle();
assert.equal(coordinator.state,'failed');assert.deepEqual(coordinator.failure?.errors,[failure,cleanup]);assert.equal(failures.length,1);
}
});
test('a stale mount cleanup failure reports its generation without failing the new room',async()=>{
const {coordinator,failures}=fixture(),mount=deferred<()=>void>(),cleanup=Error('late cleanup');
const old=coordinator.enter({invalidate(){},mount:()=>mount.promise,ready(){},handle(){}});
coordinator.enter({invalidate(){},mount(){},ready(){},handle(){}});
mount.resolve(()=>{throw cleanup;});await settle();
assert.equal(coordinator.state,'active');assert.equal(coordinator.failure,null);assert.equal(failures.length,1);
assert.equal((failures[0] as {generation:number}).generation,old.generation);
});
test('one hundred synchronous enter/exit cycles return scope resources and timers to baseline',()=>{
const {coordinator,clock,failures}=fixture();let resources=0,leases=0;
for(let cycle=0;cycle<100;cycle++){
leases++;
const scope=coordinator.enter({invalidate:()=>{leases--;},mount:scope=>{resources++;scope.own(()=>{resources--;});},ready(){},handle:message=>message()});
coordinator.dispatch(()=>{});assert.equal(coordinator.state,'active');assert.equal(scope.generation,cycle+1);
assert.deepEqual(coordinator.cancel(),[]);assert.equal(scope.resourceCount,0);assert.equal(resources,0);assert.equal(leases,0);assert.equal(clock.callbacks.size,0);
}
assert.deepEqual(failures,[]);
});
test('invalid mailbox configuration fails at its source',()=>{
for(const capacity of [0,-1,1.5,NaN])assert.throws(()=>fixture(capacity),/capacity/);
});
test('cancelling inside ready prevents queued input from reaching a revoked scope',async()=>{
const {coordinator,failures,clock}=fixture(),mount=deferred<void>();let delivered=0;
coordinator.enter({invalidate(){},mount:()=>mount.promise,ready:()=>{coordinator.cancel();},handle:()=>delivered++});
coordinator.dispatch(()=>{});mount.resolve();await settle();
assert.equal(delivered,0);assert.equal(coordinator.state,'outside');assert.equal(clock.callbacks.size,0);assert.deepEqual(failures,[]);
});
test('explicit cancellation returns every cleanup failure and remains idempotent',()=>{
const {coordinator}=fixture(),invalid=Error('lease'),ui=Error('view');const order:string[]=[];
coordinator.enter({invalidate:()=>{order.push('lease');throw invalid;},mount:scope=>{scope.own(()=>order.push('subscription'));return ()=>{order.push('view');throw ui;};},ready(){},handle(){}});
assert.deepEqual(coordinator.cancel(),[invalid,ui]);assert.deepEqual(order,['lease','view','subscription']);assert.deepEqual(coordinator.cancel(),[]);
});
test('stale rejected load does not fail or notify the new active room',async()=>{
const {coordinator,failures}=fixture(),mount=deferred<void>();
coordinator.enter({invalidate(){},mount:()=>mount.promise,ready(){},handle(){}});
coordinator.enter({invalidate(){},mount(){},ready(){},handle(){}});
mount.reject(Error('superseded load'));await settle();assert.equal(coordinator.state,'active');assert.deepEqual(failures,[]);
});
test('invalid queue deadlines fail before a room scope can be entered',()=>{
for(const maxWaitMs of [0,-1,Infinity,NaN])assert.throws(()=>new RoomCoordinator({capacity:1,maxWaitMs,clock:new Clock(),onFailure(){}}),/maxWaitMs/);
});
test('a failing diagnostic callback is retained without poisoning a newer room',async()=>{
const clock=new Clock(),mount=deferred<()=>void>(),cleanup=Error('old cleanup'),diagnostic=Error('reporter');
const coordinator=new RoomCoordinator<void>({capacity:1,maxWaitMs:500,clock,onFailure(){throw diagnostic;}});
const old=coordinator.enter({invalidate(){},mount:()=>mount.promise,ready(){},handle(){}});
coordinator.enter({invalidate(){},mount(){},ready(){},handle(){}});
mount.resolve(()=>{throw cleanup;});await settle();
assert.equal(coordinator.state,'active');assert.equal(coordinator.failure,null);
assert.equal(coordinator.diagnosticFailure?.generation,old.generation);assert.deepEqual(coordinator.diagnosticFailure?.errors,[cleanup,diagnostic]);
});
@@ -0,0 +1,38 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {SceneOrderedGameSessionHost} from '../../../YouleNexus/assets/framework/platform/room/scene-game-session.ts';
import type {GameEntry,GameHost,GameModule} from '../../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import type {ScenePort} from '../../../YouleNexus/assets/framework/platform/scene-port.ts';
function thrown(action:()=>void):unknown{let didThrow=false,value:unknown;try{action();}catch(error){didThrow=true;value=error;}assert.equal(didThrow,true);return value;}
function setup(showRoom:ScenePort['showRoom'],dispose:()=>void=()=>{}){
const failures:unknown[]=[],lease={} as GameHost;
const entry:GameEntry={key:'fixture',route:'fixture',gameId:1,resolveSeatCount:()=>4,createModule(){
let attached=false;
const module:GameModule={attach(){attached=true;},handlePlatformEvent(){},handleGameMessage(){},restore(){},dispose(){if(attached)dispose();}};
return module;
}};
const scene:ScenePort={showRoom,showLoading(){},showLogin(){},showLobby(){},showReconnect(){},showKicked(){},showServerDenial(){},showFatal(){}};
const host=new SceneOrderedGameSessionHost(entry,scene,{capacity:3,maxWaitMs:500,clock:{setTimeout(){return 1;},clearTimeout(){}},onFailure:failure=>failures.push(failure)},value=>assert.equal(value,lease));
return {host,lease,failures};
}
for(const cause of [Error('mount after stop'),undefined]){
test(`sync mount stop then throw ${String(cause)} retains exact original identity`,()=>{
const scenario=setup(()=>{scenario.host.dispose();throw cause;});
assert.equal(thrown(()=>scenario.host.open(scenario.lease)),cause);assert.equal(scenario.host.state,'disposed');assert.deepEqual(scenario.failures,[]);
});
test(`single module cleanup throw ${String(cause)} retains exact original identity`,()=>{
const scenario=setup(()=>{},()=>{throw cause;});scenario.host.open(scenario.lease);
assert.equal(thrown(()=>scenario.host.dispose()),cause);assert.equal(scenario.host.state,'disposed');scenario.host.dispose();
});
}
test('scope and module cleanup errors aggregate in execution order and both complete',()=>{
const first=Error('view'),second=Error('module');let moduleReleased=false;
const scenario=setup(scope=>{scope.own(()=>{throw first;});},()=>{moduleReleased=true;throw second;});
scenario.host.open(scenario.lease);
const failure=thrown(()=>scenario.host.dispose()) as {errors:unknown[]};assert.deepEqual(failure.errors,[first,second]);assert.equal(moduleReleased,true);
});
test('sync mount failure returns to the wire caller instead of the async diagnostic callback',()=>{
const cause=Error('synchronous load'),scenario=setup(()=>{throw cause;});
assert.equal(thrown(()=>scenario.host.open(scenario.lease)),cause);assert.deepEqual(scenario.failures,[]);scenario.host.dispose();
});
@@ -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);
});
@@ -1,4 +1,5 @@
import {createErqiwangGame} from '../../YouleNexus/assets/games/erqiwang/erqiwang-game.ts';
import {decodeFrame} from '../../YouleNexus/assets/framework/net/envelope-codec.ts';
import {GAME_DEFINITIONS} from '../fixtures/game-definitions.ts';
import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
@@ -23,8 +24,8 @@ import {
} from '../../YouleNexus/assets/framework/platform/runtime.ts';
import type { ScenePort } from '../../YouleNexus/assets/framework/platform/scene-port.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { PlatformUiController } from '../../YouleNexus/assets/framework/presentation/platform-ui-controller.ts';
import type {LoginDeviceSnapshot} from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.ts';
import { PlatformUiController } from '../../YouleNexus/assets/framework/presentation/platform-ui-controller.ts';
import type {LoginDeviceSnapshot} from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.ts';
function fixture(name: string): Record<string, unknown> {
return JSON.parse(
@@ -291,7 +292,7 @@ function makeGameEntry(overrides: Partial<GameEntry> = {}): GameEntry {
};
}
function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEntry(), device:LoginDeviceSnapshot=DEVICE) {
function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEntry(), device:LoginDeviceSnapshot=DEVICE) {
const wire = new RecordingWireClient();
const scene = new RecordingScene();
const clock = new ManualClock();
@@ -306,49 +307,49 @@ function makeRuntime(config: RuntimeConfig = runtimeConfig(), entry = makeGameEn
scene,
loadResources: async () => {},
waitForMinimumDisplay: async () => {},
getLoginDeviceSnapshot: () => device,
getLoginDeviceSnapshot: () => device,
clock,
});
return { runtime, wire, scene, clock, factoryConfigs, config };
}
test('invite identity follows authorization for non-device login and server identity for device login',async()=>{
for(const deviceLogin of [{enabled:false},{enabled:true,telphone:'13800138000'}] as const){
const {runtime,wire}=makeRuntime(runtimeConfig(),makeGameEntry(),{...DEVICE,deviceLogin});
await runtime.start();wire.emit({type:'open',server:'ws://agent'});
runtime.login({...ACCOUNT,openid:'authorized-openid',unionid:12345});
const data=fixture('player-login-success.json');
if(!deviceLogin.enabled){delete data.openid;delete data.unionid;}
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
runtime.getPlayerInvitecode();
assert.deepEqual(wire.sent[1].data,{agentid:'veRa0qrBf',playerid:430511,
openid:deviceLogin.enabled?'o1':'authorized-openid',unionid:deviceLogin.enabled?'test_unionid':12345});
wire.emit({type:'slow'});wire.emit({type:'open',server:'ws://agent'});
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
runtime.getPlayerInvitecode();assert.deepEqual(wire.sent[wire.sent.length-1].data,wire.sent[1].data);
runtime.stop();
}
});
test('binding reply numeric and string states show server feedback without closing the wire',async()=>{
for(const state of [0,'0',1,'1',-1,'-1']){
const {runtime,wire,scene}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.bindInvitecode('00123');
const success=state===0||state==='0',error=success?'绑定成功':'邀请码无效';
wire.emit({type:'message',message:{route:'agent',rpc:'binding_invitecode',data:{state,error,...(success?{invitecode:'00123'}:{})}}});
assert.equal(wire.stopCalls,0,`state=${JSON.stringify(state)} must not disconnect`);
assert.equal(runtime.state.app.phase,'logged-in');
assert.equal(runtime.state.players.entities[430511].invitecode,success?'00123':null);
assert.ok(scene.calls.some(event=>event.name==='tips'&&JSON.stringify(event.value)===JSON.stringify({message:error,time:3000})));
assert.equal(scene.calls.some(event=>event.name==='fatal'),false);
runtime.getPlayerInvitecode();assert.equal(wire.sent[wire.sent.length-1].rpc,'get_player_invitecode');
runtime.stop();
}
});
test('VIP wire query response reaches canonical state and clears on login replacement',async()=>{
test('invite identity follows authorization for non-device login and server identity for device login',async()=>{
for(const deviceLogin of [{enabled:false},{enabled:true,telphone:'13800138000'}] as const){
const {runtime,wire}=makeRuntime(runtimeConfig(),makeGameEntry(),{...DEVICE,deviceLogin});
await runtime.start();wire.emit({type:'open',server:'ws://agent'});
runtime.login({...ACCOUNT,openid:'authorized-openid',unionid:12345});
const data=fixture('player-login-success.json');
if(!deviceLogin.enabled){delete data.openid;delete data.unionid;}
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
runtime.getPlayerInvitecode();
assert.deepEqual(wire.sent[1].data,{agentid:'veRa0qrBf',playerid:430511,
openid:deviceLogin.enabled?'o1':'authorized-openid',unionid:deviceLogin.enabled?'test_unionid':12345});
wire.emit({type:'slow'});wire.emit({type:'open',server:'ws://agent'});
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
runtime.getPlayerInvitecode();assert.deepEqual(wire.sent[wire.sent.length-1].data,wire.sent[1].data);
runtime.stop();
}
});
test('binding reply numeric and string states show server feedback without closing the wire',async()=>{
for(const state of [0,'0',1,'1',-1,'-1']){
const {runtime,wire,scene}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.bindInvitecode('00123');
const success=state===0||state==='0',error=success?'绑定成功':'邀请码无效';
wire.emit({type:'message',message:{route:'agent',rpc:'binding_invitecode',data:{state,error,...(success?{invitecode:'00123'}:{})}}});
assert.equal(wire.stopCalls,0,`state=${JSON.stringify(state)} must not disconnect`);
assert.equal(runtime.state.app.phase,'logged-in');
assert.equal(runtime.state.players.entities[430511].invitecode,success?'00123':null);
assert.ok(scene.calls.some(event=>event.name==='tips'&&JSON.stringify(event.value)===JSON.stringify({message:error,time:3000})));
assert.equal(scene.calls.some(event=>event.name==='fatal'),false);
runtime.getPlayerInvitecode();assert.equal(wire.sent[wire.sent.length-1].rpc,'get_player_invitecode');
runtime.stop();
}
});
test('VIP wire query response reaches canonical state and clears on login replacement',async()=>{
const {runtime,wire}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const login=()=>wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
@@ -729,7 +730,7 @@ test('a denied join retains the lobby root, reports the raw outcome and permits
runtime.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>),
} });
assert.equal(runtime.state.room.kind, 'inside');
assert.equal(scene.calls[scene.calls.length - 1]?.name, 'room');
@@ -741,6 +742,7 @@ test('runtime resolves seats only from the actual game-owned server roomtype at
const attached: GameHost[] = [];
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
resolveSeatCount(roomtype) {
assert.ok(Array.isArray(roomtype));
roomtypes.push(roomtype);
if (roomtype.length !== 7 || roomtype[1] !== 4 || !Array.isArray(roomtype[5])) {
throw new Error('Game roomtype requires the full nested server configuration');
@@ -860,7 +862,7 @@ for (const interruption of ['attach', 'restore', 'room-scene', 'room-event'] as
route: 'agent', rpc: 'player_login', data: fixture('player-login-room.json'),
} });
assert.deepEqual(disposed, [0, 1], 'validation and actual room modules each dispose once');
assert.deepEqual(disposed, [0], 'only the actual room module is created and disposed');
assert.ok(attachedHost);
const stoppedHost = attachedHost;
assert.throws(() => stoppedHost.getSnapshot(), /disposed/);
@@ -869,8 +871,8 @@ for (const interruption of ['attach', 'restore', 'room-scene', 'room-event'] as
assert.deepEqual(setup.scene.calls.filter(call => call.name === 'fatal'), []);
assert.equal(setup.wire.stopCalls, 1);
assert.deepEqual(setup.wire.lifecycle, ['subscribe', 'start', 'unsubscribe', 'stop']);
if (interruption === 'attach') assert.deepEqual(events, ['snapshot']);
if (interruption === 'room-event' || interruption === 'room-scene') {
if (interruption === 'attach' || interruption === 'room-scene') assert.deepEqual(events, ['snapshot']);
if (interruption === 'room-event') {
assert.deepEqual(events, ['snapshot', 'room-event']);
}
const stoppedState = runtime.state;
@@ -884,7 +886,7 @@ for (const interruption of ['attach', 'restore', 'room-scene', 'room-event'] as
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
await assert.rejects(runtime.start(), /stopped/);
runtime.stop();
assert.deepEqual(disposed, [0, 1]);
assert.deepEqual(disposed, [0]);
assert.equal(setup.wire.stopCalls, 1);
});
}
@@ -985,7 +987,7 @@ function makeLoggingEntry(log: string[], restored: unknown[], attached: GameHost
dispose() {
if (!recording) return;
assert.ok(host);
host.getSnapshot();
assert.throws(() => host!.getSnapshot(), /disposed/i);
log.push('dispose');
},
};
@@ -1010,14 +1012,10 @@ function makeCleanupFailureEntry(
disposeFailure: unknown,
captured: CapturedGameHost,
): GameEntry {
let moduleNumber = 0;
return makeGameEntry({
createModule(): GameModule {
moduleNumber += 1;
const validationModule = moduleNumber === 1;
return {
attach(host) {
if (validationModule) return;
return makeGameEntry({
createModule(): GameModule {
return {
attach(host) {
captured.host = host;
host.subscribe(() => {});
},
@@ -1025,7 +1023,6 @@ function makeCleanupFailureEntry(
handleGameMessage() {},
restore() {},
dispose() {
if (validationModule) return;
log.push('game:dispose');
throw disposeFailure;
},
@@ -1300,15 +1297,11 @@ test('wire start failure cleans every established layer and preserves cleanup er
const unsubscribeFailure = { step: 'unsubscribe' };
const stopFailure = { step: 'wire.stop' };
const cleanupLog: string[] = [];
let moduleNumber = 0;
let activeHost: GameHost | null = null;
const entry = makeGameEntry({
createModule(): GameModule {
moduleNumber += 1;
const currentModule = moduleNumber;
return {
attach(host) {
if (currentModule === 1) return;
let activeHost: GameHost | null = null;
const entry = makeGameEntry({
createModule(): GameModule {
return {
attach(host) {
activeHost = host;
host.subscribe(() => {});
},
@@ -1316,7 +1309,6 @@ test('wire start failure cleans every established layer and preserves cleanup er
handleGameMessage() {},
restore() {},
dispose() {
if (currentModule === 1) return;
cleanupLog.push('game:dispose');
throw undefined;
},
@@ -1495,7 +1487,7 @@ for (const switchCase of [
target: 'wss://agent.example/socket',
data: {
agentserver: 'wss://agent.example/socket',
opt: 'free_room',
opt: 'self_exit_room',
nested: { trace: 'a' },
},
},
@@ -1578,8 +1570,8 @@ test('room login commits and opens before showRoom, then restores the exact desk
assert.equal(setup.runtime.state.room.kind, 'inside');
assert.deepEqual(log, [
'attach:100001',
'event:room.entered',
'scene:room',
'event:room.entered',
'restore',
]);
assert.deepEqual(restored, [roomLogin.deskinfo]);
@@ -1604,7 +1596,7 @@ for (const withDeskinfo of [false, true]) {
message: { route: 'agent', rpc: 'player_login', data: fixture('player-login-success.json') },
});
log.length = 0;
const join = fixture('self-join-room.json');
const join = ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>);
const deskinfo = join.deskinfo;
if (!withDeskinfo) delete join.deskinfo;
@@ -1614,8 +1606,8 @@ for (const withDeskinfo of [false, true]) {
});
assert.deepEqual(log, withDeskinfo
? ['attach:100001', 'event:room.entered', 'scene:room', 'restore']
: ['attach:100001', 'event:room.entered', 'scene:room']);
? ['attach:100001', 'scene:room', 'event:room.entered', 'restore']
: ['attach:100001', 'scene:room', 'event:room.entered']);
assert.deepEqual(restored, withDeskinfo ? [deskinfo] : []);
if (withDeskinfo) assert.equal(restored[0], deskinfo);
});
@@ -1751,7 +1743,7 @@ test('login gate routes each ignored room message once with zero runtime side ef
});
setup.wire.emit({
type: 'message',
message: { route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json') },
message: { route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>) },
});
assert.equal(routerDispatches, 2);
@@ -1825,7 +1817,7 @@ test('joinRoom, prepare, and exitRoom delegate through the authoritative platfor
}]);
assert.equal((setup.wire.sent[0]!.data as { location: unknown }).location, location);
const joinedRoom = fixture('self-join-room.json');
const joinedRoom = ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>);
delete joinedRoom.deskinfo;
joinedRoom.deskwar = 0;
setup.wire.emit({
@@ -1856,7 +1848,7 @@ test('joinRoom, prepare, and exitRoom delegate through the authoritative platfor
]);
});
test('stop unsubscribes, disposes while the host is live, invalidates it, then stops the wire', async () => {
test('stop unsubscribes, revokes the host before module disposal, then stops the wire', async () => {
const log: string[] = [];
const restored: unknown[] = [];
const attached: GameHost[] = [];
@@ -1949,16 +1941,15 @@ test('stop continues after clearTimeout and every later cleanup failure in exact
assert.deepEqual(log, [
'clock:clear',
'wire:unsubscribe',
'game:dispose',
'lease:invalidate',
'game:dispose',
'wire:stop',
]);
assert.equal(errors.length, 5);
assert.equal(errors.length, 4);
assert.equal(errors[0], clearFailure);
assert.equal(errors[1], undefined);
assert.equal(errors[2], disposeFailure);
assert.equal(errors[3], leaseFailure);
assert.equal(errors[4], stopFailure);
assert.deepEqual((errors[2] as {errors:unknown[]}).errors, [leaseFailure, disposeFailure]);
assert.equal(errors[3], stopFailure);
assert.equal(scene.calls.filter((call) => call.name === 'fatal').length, 0);
const completedLog = [...log];
@@ -2039,8 +2030,8 @@ test('kick completes close, invalidate, clear, kicked scene, and wire stop despi
});
assert.deepEqual(log, [
'game:dispose',
'lease:invalidate',
'game:dispose',
'clock:clear',
'scene:kicked',
'wire:stop',
@@ -2053,10 +2044,9 @@ test('kick completes close, invalidate, clear, kicked scene, and wire stop despi
const fatal = scene.calls.find((call) => call.name === 'fatal')?.value;
assert.ok(fatal instanceof Error);
const sessionErrors = (fatal as Error & { readonly errors: readonly unknown[] }).errors;
assert.equal(sessionErrors.length, 3);
assert.equal(sessionErrors[0], disposeFailure);
assert.equal(sessionErrors[1], leaseFailure);
const kickCleanup = sessionErrors[2];
assert.equal(sessionErrors.length, 2);
assert.deepEqual((sessionErrors[0] as {errors:unknown[]}).errors, [leaseFailure, disposeFailure]);
const kickCleanup = sessionErrors[1];
assert.ok(kickCleanup instanceof Error);
const kickErrors = (
kickCleanup as Error & { readonly errors: readonly unknown[] }
@@ -2104,7 +2094,7 @@ test('real runtime renders the room before restoring canonical deskinfo', async
data: fixture('player-login-success.json') } });
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'self_join_room',
data: { ...fixture('self-join-room.json'), deskinfo } } });
data: { ...({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>), deskinfo } } });
assert.ok(events.includes('render:lobby'));
assert.ok(events.indexOf('attach') < events.indexOf('render:room'));
assert.ok(events.indexOf('render:room') < events.indexOf('activate:room'));
@@ -2162,7 +2152,7 @@ test('real runtime keeps canonical roots and raw denials while login and join re
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>),
} });
assert.equal(runtime.state.room.kind, 'inside');
assert.equal((overlays[overlays.length - 1] as { kind: string }).kind, 'none');
@@ -2285,7 +2275,7 @@ for (const phase of ['render', 'activate'] as const) {
armed = true;
controller.joinRoom({ roomcode: '100001', location: null, ip: '127.0.0.1' });
wire.emit({ type: 'message', message: {
route: 'agent', rpc: 'self_join_room', data: fixture('self-join-room.json'),
route: 'agent', rpc: 'self_join_room', data: ({...fixture('self-join-room.json'),deskwar:0} as Record<string,unknown>),
} });
assert.equal(restores, 0);
@@ -2350,63 +2340,287 @@ test('application overlay teardown disposes Presenter, stops Runtime, and invali
assert.throws(() => runtime.login(ACCOUNT), /stopped/);
runtime.stop();
});
test('rank and card wire flow uses server balances and accepts repeated rank responses',async()=>{
const {runtime,wire}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.getTreasureList();const row={name:'甲',headimg:'',playerid:12,star:100,signature:'a#b'};
wire.emit({type:'message',message:{route:'agent',rpc:'get_treasurelist',data:{list:[row]}}});
assert.deepEqual(runtime.state.treasureList,[row]);const old=runtime.state.treasureList;
wire.emit({type:'message',message:{route:'agent',rpc:'get_treasurelist',data:{list:[row]}}});
assert.notEqual(runtime.state.treasureList,old);
runtime.redeemCard('00aB-12');const bean=runtime.state.players.entities[430511].bean;
wire.emit({type:'message',message:{route:'agent',rpc:'topup_card',data:{}}});
assert.equal(runtime.state.players.entities[430511].bean,bean);
for(const route of ['agent','room']){
wire.emit({type:'message',message:{route,rpc:'update_bean',data:{bean:123}}});
assert.equal(runtime.state.players.entities[430511].bean,123);
}
wire.emit({type:'message',message:{route:'agent',rpc:'update_bean',data:{change:20}}});
assert.equal(runtime.state.players.entities[430511].bean,123);runtime.stop();
});
test('create wire outcome enters only on success and a repeated success leaves the module mounted',async()=>{
let attachments=0,disposals=0;
const entry=makeGameEntry({resolveSeatCount:()=>10,createModule:()=>({
attach(){attachments++;},handlePlatformEvent(){},handleGameMessage(){},restore(){},dispose(){disposals++;},
})});
const {runtime,wire,scene}=makeRuntime(runtimeConfig(),entry);
await runtime.start();attachments=0;disposals=0;wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.createRoom({roomtype:[1,4],location:null,ip:'127.0.0.1'});
assert.equal(wire.sent[wire.sent.length-1]?.rpc,'create_room');
const before=runtime.state;
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{state:1,showerror:1,error:'denied'}}});
assert.equal(runtime.state,before);assert.equal(attachments,0);
assert.equal(scene.calls.some(call=>call.name==='room'||call.name==='fatal'),false);
runtime.createRoom({roomtype:[1,4],location:null,ip:'127.0.0.1'});
const data={state:0,roomcode:'123456',seat:0,roomtype:[1,4],needprepare:1,infinite:0};
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data}});
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{...data}}});
assert.equal(attachments,1);assert.equal(disposals,0);
assert.equal(scene.calls.filter(call=>call.name==='room').length,1);
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside')assert.equal(runtime.state.room.seatPlayerIds.length,10);
runtime.stop();assert.equal(disposals,1);
});
for(const responseRoomtype of ['11011000000','11011','11011000001']){
test(`erqiwang creation preserves response ${responseRoomtype}, three seats and room host exit`,async()=>{
const config=runtimeConfig(),game=GAME_DEFINITIONS.erqiwang.createGame(config.identity.gameid);
const {runtime,wire,scene}=makeRuntime(config,game.entry);await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
const roomtype='11011000000';runtime.createRoom({roomtype,location:null,ip:'127.0.0.1'});
assert.equal((wire.sent[wire.sent.length - 1]?.data as {roomtype:unknown}).roomtype,roomtype);
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{state:0,roomcode:123456,seat:0,roomtype:responseRoomtype,needprepare:1}}});
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside'){assert.equal(runtime.state.room.roomcode,'123456');assert.equal(runtime.state.room.roomtype,responseRoomtype);assert.equal(runtime.state.room.seatPlayerIds.length,3);}
assert.equal(scene.calls.filter(x=>x.name==='room').length,1);
assert.equal(scene.calls.some(x=>x.name==='fatal'),false);assert.equal(wire.stopCalls,0);
let rendered=false;game.mount({render(snapshot,actions){assert.equal(snapshot.room.roomtype,responseRoomtype);rendered=true;actions.exit();},close(){}});
assert.ok(rendered);assert.equal(wire.sent[wire.sent.length - 1]?.rpc,'self_exit_room');runtime.stop();
});
}
for(const roomtype of ['11011','11011000001']){
test(`erqiwang login restores compatible roomtype ${roomtype} without closing connection`,async()=>{
const config=runtimeConfig(),game=GAME_DEFINITIONS.erqiwang.createGame(config.identity.gameid);
const {runtime,wire,scene}=makeRuntime(config,game.entry);await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const data=fixture('player-login-room.json');
data.roomtype=roomtype;data.players=(data.players as unknown[]).slice(0,3);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
assert.equal(scene.calls.some(x=>x.name==='fatal'),false);assert.equal(wire.stopCalls,0);
assert.equal(scene.calls.filter(x=>x.name==='room').length,1);
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside')assert.equal(runtime.state.room.roomtype,roomtype);
runtime.stop();
});
}
for(const entry of ['login restore','join'] as const){
test(`numeric roomcode survives ${entry} through Router, Store and room actions`,async()=>{
const {runtime,wire,scene}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const raw={...fixture(entry==='login restore'?'player-login-room.json':'self-join-room.json'),roomcode:123456};
if(entry==='join')wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
wire.emit({type:'message',message:{route:'agent',rpc:entry==='login restore'?'player_login':'self_join_room',data:raw}});
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind!=='inside')throw Error('Room response did not commit');
assert.equal(runtime.state.room.roomcode,'123456');assert.equal(raw.roomcode,123456);
assert.equal(scene.calls.filter(call=>call.name==='room').length,1);
assert.equal(scene.calls.some(call=>call.name==='fatal'),false);
runtime.sendRoomText('测试',false);
assert.equal(wire.sent[wire.sent.length-1]?.rpc,'send_text');
assert.equal((wire.sent[wire.sent.length-1]?.data as {roomcode:string}).roomcode,'123456');
runtime.stop();
});
}
test('dissolution confirmation without deskfree returns to lobby with no exit packet',async()=>{
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
wire.emit({type:'message',message:{route:'room',rpc:'free_room',data:{seats:[null,0,null,null]}}});
const sent=wire.sent.length;runtime.acknowledgeDissolution();assert.equal(runtime.state.room.kind,'outside');assert.equal(wire.sent.length,sent);assert.equal(scene.calls[scene.calls.length-1]?.name,'lobby');runtime.stop();
});
test('freeNow without deskfree closes room and displays original tips immediately',async()=>{
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
wire.emit({type:'message',message:{route:'room',rpc:'free_room',data:{freeNow:1,tips:'房间已解散'}}});
assert.equal(runtime.state.room.kind,'outside');assert.ok(scene.calls.some(call=>call.name==='tips'&&(call.value as {message:string}).message==='房间已解散'));runtime.stop();
});
test('dissolution server switch preserves session until agent free_room confirmation',async()=>{
let disposed=0;const events:PlatformToGameEvent[]=[];
const entry=makeGameEntry({createModule:()=>({attach(){},handlePlatformEvent(event){events.push(event);},handleGameMessage(){},restore(){},dispose(){disposed++;}})});
const {runtime,wire}=makeRuntime(runtimeConfig(),entry);await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
disposed=0;
const payload={agentserver:'new-agent:9000',data:{opt:'free_room',playerid:430511,agentid:'veRa0qrBf',gameid:'41',info:{seats:[null,0,null,null],deskfree:{round:3}}}};
wire.emit({type:'message',message:{route:'room',rpc:'connect_agentserver',data:payload}});
assert.equal(disposed,0);wire.emit({type:'open',server:'ws://new-agent:9000'});assert.deepEqual(wire.sent[wire.sent.length-1]?.data,payload);
wire.emit({type:'message',message:{route:'agent',rpc:'free_room',data:payload.data.info}});
runtime.acknowledgeDissolution();assert.equal(disposed,0);assert.deepEqual(events[events.length-1],{type:'room.dissolution-confirmed',freeNow:false,deskfree:{round:3}});runtime.stop();assert.equal(disposed,1);
});
test('freeNow and dissolution server switch work in either order',async()=>{
for(const switchFirst of [true,false]){
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
const switchMessage={route:'room',rpc:'connect_agentserver',data:{agentserver:'new-agent:9000',data:{opt:'free_room'}}};
if(switchFirst){wire.emit({type:'message',message:switchMessage});wire.emit({type:'open',server:'ws://new-agent:9000'});}
wire.emit({type:'message',message:{route:switchFirst?'agent':'room',rpc:'free_room',data:{freeNow:1,tips:'已解散'}}});
assert.equal(runtime.state.room.kind,'outside');
if(!switchFirst){wire.emit({type:'message',message:switchMessage});assert.deepEqual(wire.switchCalls,['ws://new-agent:9000']);assert.equal(scene.calls.some(call=>call.name==='fatal'),false);}
else assert.equal(scene.calls.some(call=>call.name==='fatal'),false);
runtime.stop();
}
});
test('empty server self agree/refuse acknowledgement envelopes update the active vote',async()=>{
for(const rpc of ['self_agree_free_room','self_refuse_free_room']){
const {runtime,wire,scene}=makeRuntime();await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const data=fixture('player-login-room.json'),players=data.players as Record<string,unknown>[];players[0]={...players[1],playerid:999,nickname:'发起者'};
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data}});
wire.emit({type:'message',message:{route:'room',rpc:'other_apply_free_room',data:{seat:0,agreefree:{state:[0,-1,null,null],countdown:30}}}});
const decoded=decodeFrame(JSON.stringify({app:'youle',route:'room',rpc}));assert.ok(decoded.kind==='message');wire.emit({type:'message',message:decoded.message});
assert.equal(scene.calls.some(call=>call.name==='fatal'),false);assert.equal(runtime.state.dissolution?.kind,rpc==='self_agree_free_room'?'active':'result');runtime.stop();
}
});
async function runtimeWithPendingRoomMount(){
const mount=deferred<void>(),events:string[]=[],restored:unknown[]=[],hosts:GameHost[]=[];
const setup=makeRuntime(runtimeConfig(),makeGameEntry({createModule(){
let attached=false;
return {attach(host){attached=true;hosts.push(host);events.push('attach');},
handlePlatformEvent(event){events.push(event.type);},handleGameMessage(message){events.push(`game:${message.rpc}`);},
restore(value){restored.push(value);events.push('restore');},dispose(){if(attached)events.push('dispose');}};
}}));
const port:ScenePort=setup.scene;
port.showRoom=async scope=>{
events.push('mount');await mount.promise;
scope.own(()=>events.push('view:release'));
if(scope.active)events.push('view:ready');
};
await setup.runtime.start();setup.wire.emit({type:'open',server:'ws://agent'});setup.runtime.login(ACCOUNT);
const login=fixture('player-login-room.json');
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:login}});
return {...setup,mount,events,restored,hosts,login};
}
async function settleRoomMount():Promise<void>{await Promise.resolve();await Promise.resolve();await Promise.resolve();}
test('real runtime waits for scene mount before entered, exact restore, and queued game input',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'one',data:{}}});
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'two',data:{}}});
assert.deepEqual(setup.events,['attach','mount']);assert.equal(setup.clock.timers.size,1);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','view:ready','room.entered','restore','game:one','game:two']);
assert.equal(setup.restored[0],setup.login.deskinfo);assert.equal(setup.clock.timers.size,0);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('real runtime kick cancels a pending scene and never delivers its queued input',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'pending',data:{}}});
setup.wire.emit({type:'message',message:{route:'agent',rpc:'kick_server',data:{msg:'检查到新版本',code:1}}});
assert.throws(()=>setup.hosts[0]!.getSnapshot(),/disposed/);assert.equal(setup.clock.timers.size,0);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','dispose','view:release']);
assert.equal(setup.scene.calls.filter(call=>call.name==='kicked').length,1);assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);
assert.equal(setup.wire.stopCalls,1);
});
test('real runtime mount rejection fails once and releases pending input and host lease',async()=>{
const setup=await runtimeWithPendingRoomMount(),cause=Error('scene load rejected');
setup.mount.reject(cause);await settleRoomMount();
const fatals=setup.scene.calls.filter(call=>call.name==='fatal');assert.equal(fatals.length,1);
assert.deepEqual((fatals[0]!.value as {errors:unknown[]}).errors,[cause]);
assert.throws(()=>setup.hosts[0]!.getSnapshot(),/disposed/);assert.equal(setup.clock.timers.size,0);assert.equal(setup.wire.stopCalls,1);
assert.deepEqual(setup.events,['attach','mount','dispose']);setup.runtime.stop();assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,1);
});
for(const connectionEvent of [{type:'close'},{type:'reconnecting',server:'ws://agent'}] as const){
test(`real runtime ${connectionEvent.type} cancels a pending scene without late room input`,async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit(connectionEvent);assert.throws(()=>setup.hosts[0]!.getSnapshot(),/disposed/);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','dispose','view:release']);assert.equal(setup.clock.timers.size,0);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
}
test('rank and card wire flow uses server balances and accepts repeated rank responses',async()=>{
const {runtime,wire}=makeRuntime();await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.getTreasureList();const row={name:'甲',headimg:'',playerid:12,star:100,signature:'a#b'};
wire.emit({type:'message',message:{route:'agent',rpc:'get_treasurelist',data:{list:[row]}}});
assert.deepEqual(runtime.state.treasureList,[row]);const old=runtime.state.treasureList;
wire.emit({type:'message',message:{route:'agent',rpc:'get_treasurelist',data:{list:[row]}}});
assert.notEqual(runtime.state.treasureList,old);
runtime.redeemCard('00aB-12');const bean=runtime.state.players.entities[430511].bean;
wire.emit({type:'message',message:{route:'agent',rpc:'topup_card',data:{}}});
assert.equal(runtime.state.players.entities[430511].bean,bean);
for(const route of ['agent','room']){
wire.emit({type:'message',message:{route,rpc:'update_bean',data:{bean:123}}});
assert.equal(runtime.state.players.entities[430511].bean,123);
}
wire.emit({type:'message',message:{route:'agent',rpc:'update_bean',data:{change:20}}});
assert.equal(runtime.state.players.entities[430511].bean,123);runtime.stop();
});
test('create wire outcome enters only on success and a repeated success leaves the module mounted',async()=>{
let attachments=0,disposals=0;
const entry=makeGameEntry({resolveSeatCount:()=>10,createModule:()=>({
attach(){attachments++;},handlePlatformEvent(){},handleGameMessage(){},restore(){},dispose(){disposals++;},
})});
const {runtime,wire,scene}=makeRuntime(runtimeConfig(),entry);
await runtime.start();attachments=0;disposals=0;wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
runtime.createRoom({roomtype:[1,4],location:null,ip:'127.0.0.1'});
assert.equal(wire.sent[wire.sent.length-1]?.rpc,'create_room');
const before=runtime.state;
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{state:1,showerror:1,error:'denied'}}});
assert.equal(runtime.state,before);assert.equal(attachments,0);
assert.equal(scene.calls.some(call=>call.name==='room'||call.name==='fatal'),false);
runtime.createRoom({roomtype:[1,4],location:null,ip:'127.0.0.1'});
const data={state:0,roomcode:'123456',seat:0,roomtype:[1,4],needprepare:1,infinite:0};
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data}});
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{...data}}});
assert.equal(attachments,1);assert.equal(disposals,0);
assert.equal(scene.calls.filter(call=>call.name==='room').length,1);
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside')assert.equal(runtime.state.room.seatPlayerIds.length,10);
runtime.stop();assert.equal(disposals,1);
test('slow mount queues full ready packets so each event sees its own committed player state',async()=>{
const mount=deferred<void>(),seen:boolean[][]=[],events:string[]=[];let host:GameHost;
const setup=makeRuntime(runtimeConfig(),makeGameEntry({createModule:()=>({
attach(value){host=value;},handlePlatformEvent(event){
events.push(event.type);
if(event.type==='room.player-ready'){
seen.push(host.getSnapshot().seats.map(seat=>seat.ready));
setup.runtime.prepare();
}
},handleGameMessage(){events.push('game');},restore(){events.push('restore');},dispose(){},
})}));
const port:ScenePort=setup.scene;port.showRoom=()=>mount.promise;
await setup.runtime.start();setup.wire.emit({type:'open',server:'ws://agent'});setup.runtime.login(ACCOUNT);
const login=fixture('player-login-room.json'),players=login.players as Array<Record<string,unknown>|null>;
players[1]!.isprepare=0;players[2]={...players[1],playerid:430512,nickname:'同桌玩家'};
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:login}});
const pendingState=setup.runtime.state;
setup.wire.emit({type:'message',message:{route:'room',rpc:'player_prepare',data:{seat:1}}});
setup.wire.emit({type:'message',message:{route:'room',rpc:'player_prepare',data:{seat:2}}});
assert.equal(setup.runtime.state,pendingState);assert.deepEqual(events,[]);assert.deepEqual(seen,[]);
mount.resolve();await settleRoomMount();
assert.deepEqual(events,['room.entered','restore','room.player-ready','room.player-ready']);
assert.deepEqual(seen,[[true,false],[true,true]]);
assert.equal(setup.wire.sent.filter(message=>message.rpc==='player_prepare').length,2);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('erqiwang creation preserves wire string, three seats and room host exit',async()=>{
const config=runtimeConfig(),game=createErqiwangGame(config.identity.gameid);
const {runtime,wire,scene}=makeRuntime(config,game.entry);await runtime.start();
wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
const roomtype='11011000000';runtime.createRoom({roomtype,location:null,ip:'127.0.0.1'});
assert.equal((wire.sent[wire.sent.length - 1]?.data as {roomtype:unknown}).roomtype,roomtype);
wire.emit({type:'message',message:{route:'agent',rpc:'create_room',data:{state:0,roomcode:'123456',seat:0,roomtype,needprepare:1}}});
assert.equal(runtime.state.room.kind,'inside');
if(runtime.state.room.kind==='inside'){assert.equal(runtime.state.room.roomtype,roomtype);assert.equal(runtime.state.room.seatPlayerIds.length,3);}
assert.equal(scene.calls.filter(x=>x.name==='room').length,1);
let rendered=false;game.mount({render(snapshot,actions){assert.equal(snapshot.room.roomtype,roomtype);rendered=true;actions.exit();},close(){}});
assert.ok(rendered);assert.equal(wire.sent[wire.sent.length - 1]?.rpc,'self_exit_room');runtime.stop();
test('login replacement bypasses pending room packets and clears their old generation',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.wire.emit({type:'message',message:{route:'room',rpc:'player_prepare',data:{seat:1}}});
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
assert.equal(setup.runtime.state.room.kind,'outside');assert.equal(setup.clock.timers.size,0);
setup.mount.resolve();await settleRoomMount();
assert.deepEqual(setup.events,['attach','mount','dispose','view:release']);assert.equal(setup.runtime.state.room.kind,'outside');
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('authentication barrier ignores room packets at receipt even while an earlier room is mounting',async()=>{
const setup=await runtimeWithPendingRoomMount();
setup.runtime.login(ACCOUNT);
setup.wire.emit({type:'message',message:{route:'fixture-game',rpc:'ignored-during-login',data:{}}});
setup.mount.resolve();await settleRoomMount();
assert.equal(setup.events.includes('game:ignored-during-login'),false);
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);setup.runtime.stop();
});
test('one hundred real runtime room entries and lobby exits return modules, leases, subscriptions and timers to baseline',async()=>{
const hosts:GameHost[]=[];let created=0,attached=0,disposed=0,subscriptions=0;
const originalSubscribe=PlatformStore.prototype.subscribe;
PlatformStore.prototype.subscribe=function(listener){
const release=originalSubscribe.call(this,listener);subscriptions++;let live=true;
return ()=>{if(!live)return;live=false;subscriptions--;release();};
};
const setup=makeRuntime(runtimeConfig(),makeGameEntry({createModule(){
created++;
let active=false,release:(()=>void)|null=null;
return {attach(host){active=true;attached++;hosts.push(host);release=host.subscribe(()=>{});},
handlePlatformEvent(){},handleGameMessage(){},restore(){},dispose(){if(active){active=false;disposed++;release?.();}}};
}}));
try{
await setup.runtime.start();assert.equal(created,0);setup.wire.emit({type:'open',server:'ws://agent'});
const baselineSubscriptions=subscriptions,baselineTimers=setup.clock.timers.size;
for(let cycle=0;cycle<100;cycle++){
setup.runtime.login(ACCOUNT);
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-room.json')}});
assert.equal(created,cycle+1);assert.equal(attached,cycle+1);assert.equal(disposed,cycle);
assert.equal(hosts.filter(host=>host.capabilities?.scope.active).length,1);
setup.wire.emit({type:'message',message:{route:'agent',rpc:'player_login',data:fixture('player-login-success.json')}});
assert.equal(disposed,cycle+1);assert.equal(setup.runtime.state.room.kind,'outside');
assert.equal(hosts.filter(host=>host.capabilities?.scope.active).length,0);
assert.equal(subscriptions,baselineSubscriptions);assert.equal(setup.clock.timers.size,baselineTimers);
}
assert.equal(setup.scene.calls.filter(call=>call.name==='fatal').length,0);
}finally{setup.runtime.stop();PlatformStore.prototype.subscribe=originalSubscribe;}
assert.equal(subscriptions,0);assert.equal(setup.clock.timers.size,0);assert.equal(created,100);assert.equal(attached,100);assert.equal(disposed,100);
});
@@ -0,0 +1,32 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {selectApplyVote} from '../../YouleNexus/assets/scripts/platform-login/apply-vote-model.ts';
function state(count=4):any {
const participants=Array.from({length:count},(_,seat)=>({seat,playerid:seat+10,nickname:`玩家${seat}`,state:seat===0?0:seat===1?1:-1}));
return {app:{phase:'logged-in'},dissolution:{kind:'active',roomcode:'123456',selfSeat:Math.min(2,count-1),initiator:participants[0],participants,deadline:120000}};
}
test('dynamic rows follow participant seats, excluding the initiator shown in the header',()=>{
for(const count of [2,4,8,12]){
const result=selectApplyVote(state(count),0)!;
assert.equal(result.rows.length,count-1);
assert.equal(result.rows[0].nickname,'【玩家1】');assert.equal(result.rows[0].status,'同意');
assert.match(result.message,/发起投票:【玩家0】/);
}
});
test('deadline only controls countdown and controls require an unvoted connected self',()=>{
const input=state();assert.equal(selectApplyVote(input,118100)!.seconds,2);
assert.equal(selectApplyVote(input,125000)!.seconds,0);
assert.equal(selectApplyVote(input,125000)!.canVote,true);
input.dissolution.selfSeat=0;assert.equal(selectApplyVote(input,0)!.canVote,false);
input.dissolution.selfSeat=1;assert.equal(selectApplyVote(input,0)!.canVote,false);
input.dissolution.selfSeat=2;input.app.phase='reconnecting';assert.equal(selectApplyVote(input,0)!.canVote,false);
});
test('server refusal produces the named result and hides voting/countdown',()=>{
const input=state();input.dissolution={...input.dissolution,kind:'result',passed:false,refusedSeat:2,freeNow:false};
const result=selectApplyVote(input,0)!;
assert.match(result.message,/拒绝解散玩家:【玩家2】/);assert.equal(result.seconds,null);
assert.equal(result.canVote,false);assert.equal(result.result,true);
input.dissolution.freeNow=true;assert.equal(selectApplyVote(input,0),null);
input.dissolution=null;assert.equal(selectApplyVote(input,0),null);
});
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {DeferredNumericToolsPort} from '../../YouleNexus/assets/framework/presentation/deferred-numeric-tools.ts';
import type {NumericToolsPort} from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts';
function target(events:string[]):NumericToolsPort{return {
openNumberInput:request=>{events.push('input:'+request.message);},closeNumberInput:()=>{events.push('closeInput');},
showMultiplier:request=>{events.push('multiplier:'+request.values.join(','));},hideMultiplier:()=>{events.push('hideMultiplier');},
close:()=>{events.push('close');},
};}
const input=(message:string)=>({message,onConfirm:()=>{}});
const multiplier=(value:number)=>({values:[value],onConfirm:()=>{}});
test('early attach requests flush after binding in latest per-tool call order',()=>{
const port=new DeferredNumericToolsPort(),events:string[]=[];
port.openNumberInput(input('old'));port.showMultiplier(multiplier(2));port.openNumberInput(input('new'));
assert.deepEqual(events,[]);port.bind(target(events));
assert.deepEqual(events,['multiplier:2','input:new']);
port.showMultiplier(multiplier(3));assert.equal(events[events.length-1],'multiplier:3');
});
test('close and hide cancel pending requests independently',()=>{
const port=new DeferredNumericToolsPort(),events:string[]=[];
port.openNumberInput(input('cancelled'));port.showMultiplier(multiplier(2));port.closeNumberInput();
port.bind(target(events));assert.deepEqual(events,['multiplier:2']);
port.unbind();port.showMultiplier(multiplier(3));port.hideMultiplier();port.bind(target(events));
assert.deepEqual(events,['multiplier:2']);
});
test('lease close clears pending callbacks and unbinds the previous room',()=>{
const port=new DeferredNumericToolsPort(),events:string[]=[];
port.openNumberInput(input('old'));port.close();port.bind(target(events));assert.deepEqual(events,[]);
port.close();assert.deepEqual(events,['close']);port.openNumberInput(input('new'));
assert.deepEqual(events,['close']);port.bind(target(events));assert.deepEqual(events,['close','input:new']);
});
test('scene unbind retains pending requests while dropping only the current target',()=>{
const port=new DeferredNumericToolsPort(),events:string[]=[];
port.openNumberInput(input('pending'));port.unbind();port.bind(target(events));
port.unbind();port.showMultiplier(multiplier(4));assert.deepEqual(events,['input:pending']);
port.bind(target(events));assert.deepEqual(events,['input:pending','multiplier:4']);
});
test('bound dismissals forward directly and flushed requests are consumed exactly once',()=>{
const port=new DeferredNumericToolsPort(),events:string[]=[];
port.openNumberInput(input('once'));port.bind(target(events));port.closeNumberInput();port.hideMultiplier();
port.unbind();port.bind(target(events));assert.deepEqual(events,['input:once','closeInput','hideMultiplier']);
});
test('lease close unbinds even when target cleanup throws',()=>{
const port=new DeferredNumericToolsPort(),events:string[]=[];
port.bind({...target(events),close(){throw Error('cleanup failed');}});
assert.throws(()=>port.close(),/cleanup failed/);port.openNumberInput(input('next'));
assert.deepEqual(events,[]);port.bind(target(events));assert.deepEqual(events,['input:next']);
});
test('pending numeric requests receive cancellation on replacement and lease close',()=>{
const port=new DeferredNumericToolsPort();const cancelled:string[]=[];
port.openNumberInput({message:'first',onConfirm(){},onCancel(){cancelled.push('first');}});
port.openNumberInput({message:'second',onConfirm(){},onCancel(){cancelled.push('second');}});
port.showMultiplier({values:[1],onConfirm(){},onCancel(){cancelled.push('multiplier');throw Error('cancel failure');}});
assert.deepEqual(cancelled,['first']);assert.throws(()=>port.close(),/cancel failure/);assert.deepEqual(cancelled,['first','second','multiplier']);
assert.doesNotThrow(()=>port.close());
});
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {GAME_DEFINITIONS} from '../fixtures/game-definitions.ts';
test('unmigrated game settlement is explicit rather than silently acknowledging deskfree',()=>{
for(const definition of Object.values(GAME_DEFINITIONS)){
const game=definition.createGame('g'),module=game.entry.createModule();
module.attach({capabilities:{room:{subscribe:()=>()=>{},getSnapshot:()=>({})}}} as any);
try{assert.throws(()=>module.handlePlatformEvent({type:'room.dissolution-confirmed',freeNow:false,deskfree:{round:2}}),/解散结算.*尚未接入/);}
finally{module.dispose();}
}
});
test('game room exit always delegates to the bound platform policy',()=>{
for(const definition of Object.values(GAME_DEFINITIONS)){
const game=definition.createGame('g'),module=game.entry.createModule(),calls:unknown[]=[];
let snapshot:any={room:{stage:1,infinite:0}},actions:any;
module.attach({capabilities:{room:{subscribe:()=>()=>{},getSnapshot:()=>snapshot,requestExit:()=>calls.push('exit')}}} as any);
game.mount({render:(_s,a)=>{actions=a;},close(){}});
try{actions.exit();snapshot={room:{stage:0,infinite:0}};actions.exit();snapshot={room:{stage:1,infinite:1}};actions.exit();
assert.deepEqual(calls,['exit','exit','exit']);}
finally{module.dispose();}
}
});
@@ -21,10 +21,26 @@ test('all 32 UI combinations match actual server config, round-trip and fees',()
});
test('required groups have defaults and malformed saved configuration is rejected',()=>{
const r=new ErqiwangRoomRules();r.open(null);assert.equal(r.build(),'00000000000');
for(const invalid of ['00000','00000000001','2'.repeat(11),[],{},123])assert.throws(()=>r.open(invalid));
for(const invalid of ['', '0000','2'.repeat(11),[],{},123])assert.throws(()=>r.open(invalid));
assert.throws(()=>r.select(5,0));assert.throws(()=>r.select(0,2));
});
test('legacy and extended room configuration reads match server while new requests stay 11 bits',()=>{
for(const code of ['11011','110110','11011000001','11011future-extension']){
const r=new ErqiwangRoomRules();r.open(code);
const cfg=server.parse(code);
assert.deepEqual(r.selection.map(Boolean),[cfg.aset12,cfg.deductAA,cfg.nocheck,cfg.bangwang,cfg.climb]);
assert.equal(r.seatCount(code),3);
assert.equal(r.build(),'11011000000');
}
});
test('invalid room configuration reports actual wire type and value',()=>{
const r=new ErqiwangRoomRules();
assert.throws(()=>r.seatCount([0,1,0]),/received array: \[0,1,0\]/);
assert.throws(()=>r.seatCount('001'),/received string: "001"/);
});
test('platform preserves erqiwang string through canonical state and entry responses',()=>{
const code='10100000000';
assert.equal(cloneAndFreezeRoomtype(code as never),code);
@@ -0,0 +1,26 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {KeyedRows} from '../../YouleNexus/assets/framework/presentation/keyed-rows.ts';
import {ResourceLeaseCache} from '../../YouleNexus/assets/framework/presentation/resource-lease-cache.ts';
test('append preserves existing rows; updates, removals and teardown affect only owned rows',()=>{
const created:number[]=[],destroyed:number[]=[];
const rows=new KeyedRows<{id:number;text:string},{id:number}>({key:item=>item.id,create:item=>{created.push(item.id);return {id:item.id};},update:()=>{},remove:row=>destroyed.push(row.id)});
const one={id:1,text:'a'};rows.render([one]);const original=rows.get(1);
rows.render([one,{id:2,text:'b'}]);assert.equal(rows.get(1),original);assert.deepEqual(created,[1,2]);assert.deepEqual(destroyed,[]);
rows.render([{id:2,text:'changed'}]);assert.deepEqual(destroyed,[1]);rows.dispose();assert.deepEqual(destroyed,[1,2]);
});
test('avatar loads are deduplicated, late completion after release is bounded and cleanup is exact',async()=>{
let loadCount=0;const disposed:string[]=[];let resolve!:(value:string)=>void;
const cache=new ResourceLeaseCache<string>({idleCapacity:0,load:()=>{loadCount++;return new Promise(r=>resolve=r);},destroy:value=>disposed.push(value)});
const a=cache.acquire('url'),b=cache.acquire('url');assert.equal(loadCount,1);a.release();resolve('texture');
assert.equal(await b.value,'texture');assert.equal(cache.leases,1);b.release();b.release();assert.equal(cache.leases,0);assert.deepEqual(disposed,['texture']);
});
test('an idle cache retains only the explicitly configured budget and retries failed loads',async()=>{
let calls=0;const destroyed:string[]=[];
const cache=new ResourceLeaseCache<string>({idleCapacity:1,load:async url=>{calls++;if(url==='bad')throw Error('load');return url;},destroy:x=>destroyed.push(x)});
for(const url of ['a','b']){const lease=cache.acquire(url);await lease.value;lease.release();}
assert.deepEqual(destroyed,['a']);assert.equal(cache.size,1);
const bad=cache.acquire('bad');await assert.rejects(bad.value);bad.release();const retry=cache.acquire('bad');await assert.rejects(retry.value);retry.release();assert.equal(calls,4);
cache.clear();assert.deepEqual(destroyed,['a','b']);
});
@@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {NumericInputModel} from '../../YouleNexus/assets/framework/presentation/numeric-input-model.ts';
import {MultiplierModel} from '../../YouleNexus/assets/framework/presentation/multiplier-model.ts';
test('numeric input preserves string leading zeros and caller owns closing',()=>{
const results:(string|number)[]=[];
const model=new NumericInputModel({message:'请输入',begin0:true,onConfirm:value=>results.push(value)});
assert.equal(model.text,'');model.inputDigit(0);model.inputDigit(7);
assert.equal(model.confirm(),'07');assert.deepEqual(results,['07']);assert.equal(model.text,'07');
model.backspace();model.backspace();model.backspace();assert.equal(model.text,'');
});
test('numeric input number mode replaces zero and confirms number',()=>{
const model=new NumericInputModel({message:'金额',begin0:false,onConfirm:()=>{}});
assert.equal(model.text,'0');model.inputDigit(0);model.inputDigit(8);assert.equal(model.confirm(),8);
model.backspace();assert.equal(model.text,'0');
});
test('numeric input filters paste and enforces 18 digits without losing prior value',()=>{
const model=new NumericInputModel({message:'ID',begin0:true,onConfirm:()=>{}});
assert.equal(model.paste('id: 0001-23456789012345'),null);assert.equal(model.text,'000123456789012345');
assert.equal(model.inputDigit(9),'输入超出范围');assert.equal(model.paste('1'.repeat(19)),'输入超出范围');
assert.equal(model.text,'000123456789012345');model.paste('abc');assert.equal(model.text,'');
assert.throws(()=>model.inputDigit(12));
});
function storage(initial:string|null=null){
const values=new Map<string,string>();if(initial!==null)values.set('tsgame_multiple_161007',initial);
return {values,getItem:(key:string)=>values.get(key)??null,setItem:(key:string,value:string)=>{values.set(key,value);}};
}
test('calculator defaults its persisted preference at the source and resets invalid preference',()=>{
for(const initial of [null,'0','nonsense','1b2','-1']){
const saved=storage(initial);const model=new MultiplierModel({originalValues:[2],onConfirm:()=>{},storage:saved,gameid:16,agentid:1007});
assert.equal(model.text,'1');assert.equal(saved.values.get('tsgame_multiple_161007'),'1');
}
});
test('calculator uses b marker, rejects duplicate decimal and 9th character',()=>{
const model=new MultiplierModel({originalValues:[],onConfirm:()=>{},storage:storage('1.2'),gameid:16,agentid:1007});
assert.equal(model.text,'1b2');assert.equal(model.inputDecimal(),'输入不正确!');
for(const digit of [3,4,5,6,7])assert.equal(model.inputDigit(digit),null);
assert.equal(model.inputDigit(8),'输入长度超过限制!');assert.equal(model.text,'1b234567');
assert.throws(()=>model.inputDigit(-1));
});
test('calculator multiplies supplied originals with decimal precision and persists before callback',()=>{
const saved=storage('0.2');const originals=[0.1,-1.25,0];let result:number[]=[];
const model=new MultiplierModel({originalValues:originals,onConfirm:values=>{result=values;assert.equal(saved.getItem('tsgame_multiple_161007'),'0.2');},storage:saved,gameid:16,agentid:1007});
originals[0]=999;assert.deepEqual(model.confirm(),[0.02,-0.25,0]);assert.deepEqual(result,[0.02,-0.25,0]);
});
test('calculator zero confirms as one and normalizes display',()=>{
const model=new MultiplierModel({originalValues:[3],onConfirm:()=>{},storage:storage(),gameid:16,agentid:1007});
model.backspace();assert.equal(model.text,'0');model.inputDecimal();assert.equal(model.text,'0b');
assert.deepEqual(model.confirm(),[3]);assert.equal(model.text,'1');assert.equal(model.multiplier,1);
});
test('calculator refuses non-finite original values instead of fabricating results',()=>{
assert.throws(()=>new MultiplierModel({originalValues:[NaN],onConfirm:()=>{},storage:storage(),gameid:16,agentid:1007}));
});
test('calculator decimal multiplication supports finite scientific notation',()=>{
const model=new MultiplierModel({originalValues:[1e30,1e-7],onConfirm:()=>{},storage:storage('0.2'),gameid:16,agentid:1007});
assert.deepEqual(model.confirm(),[2e29,2e-8]);
});
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {OwnedFeedback} from '../../YouleNexus/assets/framework/presentation/owned-feedback.ts';
test('loading owners include legacy platform activity and only last release hides the surface',()=>{
const shown:boolean[]=[];const owned=new OwnedFeedback({loading:v=>shown.push(v),tip(){},closeTip(){}});
const a=owned.beginLoading(),b=owned.beginLoading();owned.setPlatformLoading(true);a.close();b.close();assert.equal(shown[shown.length-1],true);
owned.setPlatformLoading(false);assert.equal(shown[shown.length-1],false);a.close();assert.equal(shown.length,6);
});
test('closing an old tip owner never closes a newer business or platform tip',()=>{
const log:string[]=[];const owned=new OwnedFeedback({loading(){},tip:message=>log.push(message),closeTip:()=>log.push('closed')});
const first=owned.showBusinessTip('first',1),second=owned.showBusinessTip('second',1);first.close();assert.deepEqual(log,['first','second']);
second.close();second.close();assert.deepEqual(log,['first','second','closed']);owned.dispose();assert.throws(()=>owned.beginLoading(),/disposed/);
});
test('feedback cleanup attempts both surfaces and old owner handles become inert',()=>{
const log:string[]=[];const owned=new OwnedFeedback({loading:v=>{log.push('loading:'+v);if(!v)throw Error('loading cleanup');},tip(){},closeTip:()=>{log.push('tip');throw Error('tip cleanup');}});
const handle=owned.beginLoading();assert.throws(()=>owned.dispose(),error=>{assert.equal((error as Error&{errors:unknown[]}).errors.length,2);return true;});
assert.deepEqual(log,['loading:true','loading:false','tip']);assert.doesNotThrow(()=>handle.close());assert.doesNotThrow(()=>owned.dispose());
});
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {PlatformSettings} from '../../YouleNexus/assets/framework/presentation/platform-settings.ts';
function storage(){const values=new Map<string,string>();return {values,getItem:(k:string)=>values.get(k)??null,setItem:(k:string,v:string)=>{values.set(k,v);}};}
test('browser switches default on, persist independently, and restore after reopening',()=>{
const store=storage();const settings=new PlatformSettings(store,568,3,{kind:'browser'});
assert.equal(settings.read('shake'),true);assert.equal(settings.read('voice'),true);
assert.equal(settings.toggle('shake'),false);assert.equal(settings.toggle('voice'),false);
const reopened=new PlatformSettings(store,568,3,{kind:'browser'});
assert.equal(reopened.read('shake'),false);assert.equal(reopened.read('voice'),false);
assert.equal(reopened.toggle('shake'),true);
assert.equal(store.getItem('tsgame_shake_5683'),'1');assert.equal(store.getItem('tsgame_voice_5683'),'0');
assert.equal(new PlatformSettings(store,568,4,{kind:'browser'}).read('voice'),true);
});
test('lobby toggles preserve native voice and shake sound commands without starting a game shake action',()=>{
const store=storage();const calls:unknown[]=[];
const settings=new PlatformSettings(store,568,3,{kind:'bridge',call:(name,data)=>calls.push([name,data])});
settings.toggle('voice');settings.toggle('voice');settings.toggle('shake');
store.setItem('tsgame_sound_5683','0');settings.toggle('shake');settings.toggle('sound');
assert.deepEqual(calls,[['voicePlaying',0],['voicePlaying',1],['stopshake',''],['SwitchShake',0],['SwitchShake',1]]);
});
test('invalid saved preferences are exposed rather than silently replaced',()=>{
const store=storage();store.setItem('tsgame_voice_5683','bad');
const settings=new PlatformSettings(store,568,3,{kind:'browser'});
assert.throws(()=>settings.toggle('voice'),/tsgame_voice_5683/);
assert.equal(store.getItem('tsgame_voice_5683'),'bad');
});
test('entering the lobby restores a cached muted voice state to native',()=>{
const store=storage();store.setItem('tsgame_voice_5683','0');const calls:unknown[]=[];
const settings=new PlatformSettings(store,568,3,{kind:'bridge',call:(name,data)=>calls.push([name,data])});
settings.synchronizeVoice();
assert.deepEqual(calls,[['voicePlaying',0]]);
assert.equal(settings.read('voice'),false);
});
@@ -0,0 +1,84 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {runInNewContext} from 'node:vm';
import {buildRoomMenuModel, roomMenuAction, roomEscapeConfirmation, type RoomMenuContext} from '../../YouleNexus/assets/framework/presentation/room-menu-model.ts';
import {resolveRoomChatPhrase, validateRoomChatText, roomChatByteLength, resolveRoomChatPolicy} from '../../YouleNexus/assets/framework/presentation/room-chat-config.ts';
import {getGameDefinition} from '../fixtures/game-definitions.ts';
const getRoomChatConfig=(key:string)=>getGameDefinition(key).chat;
import {resolveRoomMenuContext} from '../../YouleNexus/assets/framework/presentation/room-menu-context.ts';
const context: RoomMenuContext = {status:1,canexit:1,stage:0,playercnt:2,roomMode:1,infinite:0,shortcode:0,matchId:0,h5Version:1,appdownload:'https://example.test/app',hideEscape:false,mainSceneButton:false,vipInfinite:false,versionState:0,roomCardName:'房卡',bean:200,starName:'金币'};
const ids=(patch:Partial<RoomMenuContext>)=>buildRoomMenuModel({...context,...patch}).map(button=>button.spiritId);
test('legacy panel keeps ordered, compact visible buttons and final infinite override',()=>{
assert.deepEqual(ids({}),[151,150,185,233,513,656]);
assert.deepEqual(buildRoomMenuModel(context).map(button=>button.y),[75,168,261,354,447,540]);
assert.deepEqual(ids({stage:1,canexit:0}),[151,150,185,656]);
assert.deepEqual(ids({stage:1,canexit:1}),[151,150,233,656]);
assert.deepEqual(ids({stage:1,canexit:0,infinite:1}),[151,150,233,3014,656]);
assert.deepEqual(ids({infinite:1,shortcode:123}),[151,150,233,656]);
assert.deepEqual(ids({infinite:1,vipInfinite:true}),[151,150,233,656]);
});
test('legacy escape guards and match hide ordering are preserved',()=>{
for(const patch of [{status:2},{playercnt:3},{roomMode:0},{hideEscape:true},{mainSceneButton:true}])assert.ok(!ids(patch).includes(513));
assert.ok(ids({matchId:3}).includes(185));
assert.ok(!ids({h5Version:0}).includes(656));
assert.ok(!ids({appdownload:''}).includes(656));
});
test('menu actions distinguish owner exit, pregame break and in-game dissolution',()=>{
assert.deepEqual(roomMenuAction(233,context),{kind:'confirm',checkType:2,message:'房间仍存在,是否退出?',command:'room.exit'});
assert.deepEqual(roomMenuAction(233,{...context,status:2}),{kind:'command',command:'room.exit'});
assert.deepEqual(roomMenuAction(185,context),{kind:'confirm',checkType:1,message:'解散房间不扣房卡,是否解散?',command:'room.break'});
assert.equal(roomMenuAction(185,{...context,versionState:1}).message,' 是否解散房间?');
assert.deepEqual(roomMenuAction(185,{...context,stage:1,canexit:0}),{kind:'command',command:'room.apply-dissolution'});
assert.throws(()=>roomMenuAction(3014,context),/not visible/);
});
test('escape asks for quantity then keeps legacy confirmation and balance error',()=>{
assert.deepEqual(roomMenuAction(513,context),{kind:'input',message:'请输入投降扣除数量(底翻)'});
assert.deepEqual(roomEscapeConfirmation(context,30),{kind:'confirm',checkType:3,message:'将扣除30金币,确定逃跑?',command:'room.escape',count:30});
assert.deepEqual(roomEscapeConfirmation(context,300),{kind:'tip',message:'输入大于您拥有的300金币数:200!'});
assert.throws(()=>roomEscapeConfirmation(context,NaN));
});
test('chat config uses exact game source, paired audio and one-based incoming indexes',()=>{
const template=getRoomChatConfig('Game_Surface_3'),eqw=getRoomChatConfig('erqiwang');
assert.equal(resolveRoomChatPhrase(template,1).text,'你好');
assert.equal(resolveRoomChatPhrase(eqw,1).text,'快点吧,我等的花儿都谢了。');
assert.equal(resolveRoomChatPhrase(eqw,7).text,'我先走了,后会有期');
assert.ok(eqw.phrases.every(phrase=>phrase.audio===''));
assert.equal(eqw.limitLength,40);assert.equal(eqw.showChatMs,2000);
assert.throws(()=>getRoomChatConfig('unknown'),/Unknown/);
assert.equal(resolveRoomChatPhrase(eqw,8).text,eqw.phrases[0].text);
for(const index of [0,1.5,NaN])assert.throws(()=>resolveRoomChatPhrase(eqw,index),/index/);
});
test('chat validation preserves source byte count and substring filtering',()=>{
const config=getRoomChatConfig('Game_Surface_3');
assert.equal(roomChatByteLength('a^你😀'),9);
assert.doesNotThrow(()=>validateRoomChatText(config,'你好'));
assert.throws(()=>validateRoomChatText(config,' '),/请输入/);
assert.doesNotThrow(()=>validateRoomChatText(config,'你'.repeat(40)));
assert.throws(()=>validateRoomChatText(config,'你'.repeat(41)),/40/);
assert.doesNotThrow(()=>validateRoomChatText(config,'你'.repeat(21),true));
assert.throws(()=>validateRoomChatText(config,'毛泽东'),/内容含有敏感词汇请重新输入!/);
assert.equal(config.announcementLimit,10000);assert.equal(config.voiceHistoryTag,20000);
assert.deepEqual(resolveRoomChatPolicy({raw:{},getValue:()=>undefined}),{noticeLimit:-1,noticeCost:0});
assert.deepEqual(resolveRoomChatPolicy({raw:{},getValue:key=>key==='noticelimit'?100:2}),{noticeLimit:100,noticeCost:2});
});
test('room menu source resolver reports missing authoritative room state',()=>{
assert.throws(()=>resolveRoomMenuContext({room:{kind:'outside'}} as any,{} as any,getGameDefinition('Game_Surface_3').roomMenu,{h5Version:1,versionState:0}),/inside room/);
const state:any={room:{kind:'inside',roomcode:'123456',stage:0,infinite:0,roomMode:1,status:1,canexit:1,shortcode:null,matchId:null,seatPlayerIds:[1,null]},players:{selfPlayerId:1,entities:{1:{bean:100}}}};
const config:any={identity:{},remoteConfig:{raw:{},getValue:()=>undefined}};
const resolved=resolveRoomMenuContext(state,config,getGameDefinition('Game_Surface_3').roomMenu,{h5Version:1,versionState:0});
assert.equal(resolved.playercnt,1);assert.equal(resolved.shortcode,null);assert.equal(resolved.bean,100);
assert.equal(resolved.roomCardName,'房卡');assert.equal(resolved.appdownload,'');
delete state.room.status;
assert.throws(()=>resolveRoomMenuContext(state,config,getGameDefinition('Game_Surface_3').roomMenu,{h5Version:1,versionState:0}),/status/);
});
test('both configured banned corpora match their own authoritative legacy source',()=>{
for(const gameKey of ['Game_Surface_3','erqiwang']){
const source=readFileSync(new URL(`../../../projects/${gameKey}/js/00_Surface/03_Banwords.js`,import.meta.url),'utf8');
const literal=/var banwords\s*=\s*("[\s\S]*?");/.exec(source);
assert.ok(literal);
const words=String(runInNewContext(literal[1])).split('、');
assert.deepEqual(getRoomChatConfig(gameKey).bannedWords,words);
}
});
@@ -0,0 +1,26 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {shouldShowOwnerNotice} from '../../YouleNexus/assets/framework/presentation/room-owner-notice.ts';
test('owner notice writes game-agent scoped expiry once per 24 hours including exact boundary',()=>{
const values=new Map<string,string>();let writes=0;
const storage={getItem:(key:string)=>values.get(key)??null,setItem:(key:string,value:string)=>{writes++;values.set(key,value);}};
const now=1000,day=24*60*60*1000;
assert.equal(shouldShowOwnerNotice(storage,'12','34',now),true);
assert.equal(values.get('tsgame_ownerNoteTime_1234'),String(now+day));
assert.equal(shouldShowOwnerNotice(storage,'12','34',now+day-1),false);
assert.equal(writes,1);
assert.equal(shouldShowOwnerNotice(storage,'12','34',now+day),true);
assert.equal(values.get('tsgame_ownerNoteTime_1234'),String(now+2*day));
assert.equal(shouldShowOwnerNotice(storage,'12','35',now),true);
});
test('owner notice treats missing, empty and zero expiry as due but exposes corrupt storage',()=>{
for(const value of [null,'','0']){
let saved='';assert.equal(shouldShowOwnerNotice({getItem:()=>value,setItem:(_key,value)=>{saved=value;}},'1','2',10),true);
assert.equal(saved,'86400010');
}
for(const value of ['abc','NaN','Infinity','-1','1x',' ','1.5']){
assert.throws(()=>shouldShowOwnerNotice({getItem:()=>value,setItem:()=>assert.fail('must not overwrite corrupt source')},'1','2',10),/expiry/);
}
assert.throws(()=>shouldShowOwnerNotice({getItem:()=>null,setItem:()=>{}},'1','2',NaN),/now/);
});
@@ -1,14 +1,14 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createTemplateGame} from '../../YouleNexus/assets/games/template/template-game.ts';
import {GAME_DEFINITIONS} from '../fixtures/game-definitions.ts';
test('template module attaches only when opened and releases its view and subscription',()=>{
let unsubscribed=0;const commands:unknown[]=[];const views:unknown[]=[];
const snapshot={connection:{phase:'logged-in'},self:{playerId:1,seat:0},room:{roomcode:'123456',roomtype:[],stage:0,needprepare:0,infinite:0},seats:[]};
const game=createTemplateGame('g');const probe=game.entry.createModule();probe.dispose();
const game=GAME_DEFINITIONS.Game_Surface_3.createGame('g');const probe=game.entry.createModule();probe.dispose();
assert.throws(()=>game.mount({render:s=>views.push(s),close(){}}));
const module=game.entry.createModule();
module.attach({getSnapshot:()=>snapshot,subscribe:()=>()=>unsubscribed++,execute:(c:unknown)=>commands.push(c),seat:{toView:(n:number)=>n,toServer:(n:number)=>n},sendGameMessage(){}} as any);
let actions:any;game.mount({render:(s,a)=>{views.push(s);actions=a;},close:()=>views.push('closed')});
const module=game.entry.createModule();assert.equal(game.roomProjection,null);assert.equal(module.roomProjection,null);
module.attach({capabilities:{room:{getSnapshot:()=>snapshot,subscribe:()=>()=>unsubscribed++,prepare:()=>commands.push({type:'room.prepare'}),requestExit:()=>commands.push({type:'room.exit'})}}} as any);
let actions:any;game.mount({render:(s,a,projection)=>{assert.equal(projection,null);views.push(s);actions=a;},close:()=>views.push('closed')});
actions.exit();actions.prepare();assert.deepEqual(commands,[{type:'room.exit'},{type:'room.prepare'}]);
const deskinfo={nested:[1,2]};module.restore(deskinfo);assert.equal(game.restoredSnapshot,deskinfo);
module.dispose();assert.equal(unsubscribed,1);assert.equal(views[views.length - 1],'closed');assert.throws(()=>actions.exit());
@@ -204,15 +204,18 @@ test('parseLoginResponse 拒绝负数、越界及非整数 seat', () => {
}
});
test('parseSelfJoinRoomResponse 接受金样,不深验 roomtype/deskinfo 并保留引用', () => {
test('parseSelfJoinRoomResponse 接受可传输的 opaque 游戏字段并保留引用', () => {
const data = fixture('self-join-room.json').data;
const opaqueDeskinfo = new Date();
const roomtype = [undefined, { gameDefined: true }];
const parsed = parseSelfJoinRoomResponse({ ...data, roomtype, deskinfo: opaqueDeskinfo });
const opaqueDeskinfo = { future: { round: 9, unknownCards: [17, 29] } };
const roomtype = [null, { gameDefined: true, futureRule: ['unknown', 7] }];
const unknownGameField = { nextVersion: ['kept', false] };
const parsed = parseSelfJoinRoomResponse({ ...data, roomtype, deskinfo: opaqueDeskinfo, unknownGameField });
assert.equal(parsed.state, 0);
assert.equal(parsed.room?.roomtype, roomtype);
assert.equal(parsed.reconnect.present, true);
assert.equal(parsed.reconnect.present && parsed.reconnect.value, opaqueDeskinfo);
assert.equal(parsed.raw.unknownGameField, unknownGameField);
assert.throws(() => parseSelfJoinRoomResponse({ ...data, roomtype: [undefined, { gameDefined: true }] }), /\$\.data\.roomtype\.0.*finite JSON value/);
});
test('parseSelfJoinRoomResponse defines the optional deskwar default once and deskinfo wins stage', () => {
@@ -6,7 +6,12 @@ import {
} from '../../YouleNexus/assets/framework/protocol/first-slice-routes.ts';
test('FIRST_SLICE_RPC_ROUTE 是第一纵向切片唯一且精确的 rpc route 表', () => {
assert.deepEqual(FIRST_SLICE_RPC_ROUTE, {
assert.deepEqual(FIRST_SLICE_RPC_ROUTE, {
broadcast: 'agent',
beanroom_surrender:'room',send_text:'room',send_voice:'room',send_phiz:'room',self_break_room:'room',other_break_room:'room',change_room:'room',change_seat:'room',
self_apply_free_room:'room',other_apply_free_room:'room',
self_agree_free_room:'room',other_agree_free_room:'room',
self_refuse_free_room:'room',other_refuse_free_room:'room',free_room:'room',
getVipRankList: 'agent',
get_treasurelist: 'agent',
topup_card: 'agent',
@@ -21,7 +26,10 @@ test('FIRST_SLICE_RPC_ROUTE 是第一纵向切片唯一且精确的 rpc route
player_login: 'agent',
create_room: 'agent',
self_join_room: 'agent',
player_prepare: 'room',
player_prepare: 'room',
// docs/protocol/03-协议-room路由.md; legacy Net → Desk → StartWar receives the full packet.
self_makewar: 'room',
other_makewar: 'room',
self_exit_room: 'room',
other_join_room: 'room',
other_exit_room: 'room',
@@ -0,0 +1,11 @@
import assert from 'node:assert/strict';
import {test} from 'node:test';
import {requireRoomtype} from '../../YouleNexus/assets/framework/protocol/contracts/validation.ts';
import {cloneAndFreezeRoomtype} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
test('opaque JSON roomtype preserves all finite scalar and container formats',()=>{
for(const value of [null,false,true,0,1.25,'001010',[1,{unknown:[null,true]}],{mode:'0001',options:[3,2,1]}]){assert.equal(requireRoomtype(value,'roomtype'),value);assert.deepEqual(cloneAndFreezeRoomtype(value),value);}
});
test('opaque JSON transport rejects unsupported values without executing accessors',()=>{
const cycle:any={};cycle.self=cycle;const getter=Object.defineProperty({},'x',{enumerable:true,get(){throw Error('executed');}});
for(const value of [undefined,NaN,Infinity,1n,()=>1,cycle,new Date(),[undefined],new Array(2),getter]){assert.throws(()=>requireRoomtype(value,'roomtype'),TypeError);assert.throws(()=>cloneAndFreezeRoomtype(value),TypeError);}
});
@@ -96,7 +96,7 @@ function makeHandlers() {
}),
});
const connection = new ConnectionFixture();
const handlers = new PlatformHandlers({ store, gameSession, session, connection });
const handlers = new PlatformHandlers({ store, gameSession, session, connection,showMessage(){} });
const router = new Router({ session, platform: handlers, gameSession });
return {
store,
@@ -183,6 +183,7 @@ test('Router drains reentrant room entries FIFO so each module receives its own
});
const roomA = fixture('self-join-room.json');
const roomB = fixture('self-join-room.json');
roomA.deskwar=0;roomB.deskwar=0;
roomA.roomcode = 'room-A';
roomB.roomcode = 'room-B';
roomA.deskinfo = { round: 'A' };
@@ -285,6 +286,7 @@ test('PlatformHandlers commits every room event before one typed game event read
let received = 0;
const unsubscribe = setup.store.subscribe(() => { order.push('store'); });
setup.observeEvent((event, snapshot) => {
if(event.type==='room.started'){assertSnapshot(snapshot);return;}
received += 1;
order.push('game');
assert.equal(event.type, expectedType);
@@ -405,7 +407,7 @@ test('PlatformHandlers preserves complete switch payloads and builds the exact r
const setup = makeHandlers();
const roomData = { roomserver: '10.0.0.2:3088', epoch: 7 };
const agentData = {
agentserver: 'secure.example:443/path', opt: 'free_room', trace: { id: 'x' },
agentserver: 'secure.example:443/path', opt: 'self_exit_room', trace: { id: 'x' },
};
assert.equal(setup.router.dispatch({
@@ -489,7 +491,7 @@ test('unknown and every route-mismatched platform rpc leave all observable state
}
for (const [rpc, expectedRoute] of rpcsByRoute) {
for (const route of ['platform', 'agent', 'room'] as const) {
if (route === expectedRoute || rpc==='player_login') continue;
if (route === expectedRoute || rpc==='player_login'||rpc==='connect_agentserver'&&route==='room') continue;
assert.throws(
() => setup.router.dispatch({ route, rpc, data: {} }),
/route mismatch/i,
@@ -555,7 +557,7 @@ test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves ful
extra: { trace: 1 },
};
const agentData = {
agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 },
agentserver: 'WSS://secure.example:443/socket', opt: 'self_exit_room', extra: { trace: 2 },
};
setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: roomData });
@@ -675,3 +677,38 @@ test('kick_server attempts dispose, invalidate, mark, and stop once then aggrega
assert.equal(setup.getInvalidations(), 1);
assert.equal(setup.connection.stopCalls.length, 1);
});
test('active dispatcher receives dissolution and publishes immediate settlement contract without disposal',()=>{
const fixture=makeHandlers();fixture.session.markLoginPending();fixture.handlers.dispatch({route:'agent',rpc:'player_login',data:fixtureData()});
const events:PlatformToGameEvent[]=[];fixture.observeEvent(event=>events.push(event));
fixture.handlers.dispatch({route:'room',rpc:'self_apply_free_room',data:{agreefree:{state:[null,0,null,null],countdown:60}}});
assert.equal(fixture.store.getState().dissolution?.kind,'active');
fixture.handlers.dispatch({route:'room',rpc:'free_room',data:{freeNow:1,deskfree:{round:4},tips:'解散'}});
assert.deepEqual(events,[{type:'room.dissolution-confirmed',freeNow:true,deskfree:{round:4},tips:'解散'}]);
assert.equal(fixture.gameSession.state,'active');assert.equal(fixture.getDisposals(),0);
function fixtureData(){return JSON.parse(readFileSync(new URL('../fixtures/contracts/player-login-room.json',import.meta.url),'utf8')).data;}
});
test('room social ingress and confirmed break traverse Router and clear game ownership once',()=>{
const setup=makeHandlers();setup.router.dispatch({route:'agent',rpc:'player_login',data:fixture('player-login-room.json')});
setup.router.dispatch({route:'room',rpc:'send_text',data:{seat:1,text:'network',type:0}});
assert.equal(setup.store.getState().roomSocial?.history[0].text,'network');
setup.router.dispatch({route:'room',rpc:'change_seat',data:{seat1:1,seat2:0}});
const room=setup.store.getState().room;assert.ok(room.kind==='inside');assert.equal(room.selfSeat,0);
const before=setup.getDisposals();setup.router.dispatch({route:'room',rpc:'self_break_room',data:{roomcode:'100001'}});
assert.equal(setup.store.getState().room.kind,'outside');assert.equal(setup.store.getState().roomSocial,undefined);assert.equal(setup.getDisposals(),before+1);
setup.router.dispatch({route:'room',rpc:'self_break_room',data:{}});assert.equal(setup.getDisposals(),before+1);
});
test('all wire start paths preserve the original complete envelope after committed public state',()=>{
for(const rpc of ['self_makewar','other_makewar','player_prepare','other_join_room','self_join_room'] as const){
const setup=makeHandlers();const login=fixture('player-login-room.json');const players=login.players as any[];players[0]={...players[1],playerid:99,isprepare:0,onstate:0};players[1].isprepare=0;setup.session.handleLogin(login);
const events:PlatformToGameEvent[]=[];setup.observeEvent((event,snapshot)=>{events.push(event);if(event.type==='room.started'){assert.equal(snapshot.room.stage,1);if(rpc==='player_prepare')assert.equal(snapshot.seats.find(s=>s.seat===1)?.ready,true);}});
const data=rpc==='self_join_room'?{...fixture('self-join-room.json'),deskwar:1}:rpc==='other_join_room'?{seat:2,playerid:77,nickname:'new',avatar:'',sex:1,ip:'127.0.0.2',onstate:0,bean:90,isprepare:0,deskwar:1}:rpc==='player_prepare'?{seat:1,deskwar:1}:{unknownGameField:{cards:[1,2]}};
const message={route:rpc==='self_join_room'?'agent':'room',rpc,data,unknownEnvelopeField:42};
setup.handlers.dispatch(message);
const started=events.filter(e=>e.type==='room.started');assert.equal(started.length,1);assert.equal(started[0].sourceRpc,rpc);assert.equal(started[0].message,message);
if(rpc==='player_prepare')assert.deepEqual(events.map(e=>e.type),['room.player-ready','room.started']);
if(rpc==='other_join_room')assert.deepEqual(events.map(e=>e.type),['room.player-joined','room.started']);
}
});
@@ -0,0 +1,38 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {parseCreateRoomResponse,parseSelfJoinRoomResponse} from '../../YouleNexus/assets/framework/protocol/contracts/room-contracts.ts';
import {parseLoginResponse} from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.ts';
import {PlatformStore} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
const fixture=(name:string)=>JSON.parse(readFileSync(new URL('../fixtures/contracts/'+name,import.meta.url),'utf8')).data;
test('Store verifies numeric raw identity without permitting forged parsed room IDs',()=>{
const store=new PlatformStore();store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json')));
const before=store.getState();
const parsed=parseSelfJoinRoomResponse({...fixture('self-join-room.json'),roomcode:123456});
assert.ok(parsed.room);
const room=parsed.room;
assert.throws(()=>store.replaceRoom({...parsed,room:{...room,roomcode:'different'}}),/roomcode/);
assert.equal(store.getState(),before);
store.replaceRoom(parsed);
assert.equal(store.getState().room.kind,'inside');
});
const cases=[
{name:'create',parse:parseCreateRoomResponse,data:()=>({state:0,seat:0,roomtype:'11011000000'})},
{name:'join',parse:parseSelfJoinRoomResponse,data:()=>fixture('self-join-room.json')},
{name:'login restore',parse:parseLoginResponse,data:()=>fixture('player-login-room.json')},
];
for(const entry of cases){
test(entry.name+' normalizes numeric room codes without changing wire data or string identity',()=>{
for(const roomcode of [123456,'123456','001234']){
const data={...entry.data(),roomcode};const parsed=entry.parse(data);
assert.equal(parsed.room?.roomcode,String(roomcode));
assert.equal(parsed.raw,data);assert.equal(data.roomcode,roomcode);
}
});
test(entry.name+' rejects invalid numeric room codes and unrelated types at ingress',()=>{
for(const roomcode of [NaN,Infinity,-1,1.5,Number.MAX_SAFE_INTEGER+1,null,true,{},[]]){
assert.throws(()=>entry.parse({...entry.data(),roomcode}),/roomcode/);
}
});
}
@@ -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();
});