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:
@@ -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="游戏 & 测试"/><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) {
|
||||
|
||||
+99
-26
@@ -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());
|
||||
});
|
||||
+23
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user