feat: integrate login flow, lobby UI and bitmap font resources

This commit is contained in:
2026-09-06 13:46:57 +08:00
parent e1bcfd91ec
commit 842c68ef55
141 changed files with 11279 additions and 1717 deletions
@@ -6,8 +6,9 @@ import ts from 'typescript';
import { ConfigFetchError, ConfigParseError } from '../../YouleNexus/assets/framework/config/remote-config.ts';
// 在引擎资源边界验证错误、JSON 解析和缓存释放,不以 HTTP 模拟项目资源。
function fixture(debug: boolean, text: string, error?: Error) {
function fixture(debug: boolean, text: string, error?: Error, debugIO = false) {
const calls: string[] = [];
const logs: unknown[][] = [];
const exports: any = {};
class TextAsset {}
const resources = {
@@ -19,15 +20,25 @@ function fixture(debug: boolean, text: string, error?: Error) {
const source = readFileSync(new URL('../../YouleNexus/assets/scripts/platform-login/local-config-fetcher.ts', import.meta.url), 'utf8');
runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText, { exports, require(name: string) {
}).outputText, { exports, console: {log: (...args:unknown[])=>logs.push(args)}, require(name: string) {
if (name === 'cc') return { resources, TextAsset };
if (name === 'cc/env') return { DEBUG: debug };
if (name.endsWith('/profiles.ts')) return { STARTUP_CONFIG: { debugIO } };
if (name.endsWith('/remote-config.ts')) return { ConfigFetchError, ConfigParseError };
throw Error(`Unexpected import: ${name}`);
} });
return { fetcher: new exports.CocosLocalConfigFetcher(), calls };
return { fetcher: new exports.CocosLocalConfigFetcher(), calls, logs };
}
test('config logging emits the parsed object and is silent when disabled', async () => {
for(const enabled of [false,true]){
const f=fixture(true,'{"showVisitor":1}',undefined,enabled);
const data=await f.fetcher.fetch('dev-config/test');
assert.deepEqual(f.logs,enabled?[['[Config LOCAL]','dev-config/test.txt',data]]:[]);
if(enabled)assert.equal(typeof f.logs[0][2],'object');
}
});
test('project txt parses JSON and releases the asset after every load', async () => {
const f = fixture(true, '{"showVisitor":1}');
assert.equal((await f.fetcher.fetch('dev-config/test')).showVisitor, 1);
@@ -25,11 +25,11 @@ test('resolveGameServer:空 gameconfig 使用配置源,非空值使用解码
});
test('one frozen source defines the complete default startup configuration', () => {
assert.deepEqual(STARTUP_CONFIG, { mode: 'debug', configSource: 'remote', localConfigResource: 'dev-config/update_jsonv2', gameserver: 'https://tsgames.daoqi88.cn/config_test/update_jsonv2.txt', useLocalServer: true, localServers: ['ws://127.0.0.1:3088'] });
assert.deepEqual(STARTUP_CONFIG, { debugIO: true, mode: 'debug', configSource: 'remote', localConfigResource: 'dev-config/update_jsonv2', gameserver: 'https://tsgames.daoqi88.cn/config_test/update_jsonv2.txt', useLocalServer: true, localServers: ['ws://127.0.0.1:3088'] });
assert.ok(Object.isFrozen(STARTUP_CONFIG)); assert.ok(Object.isFrozen(STARTUP_CONFIG.localServers));
});
test('source validation rejects malformed configuration without defaults', () => {
for (const patch of [{mode:'other'}, {useLocalServer:undefined}, {gameserver:'ws://host'}, {gameserver:''}, {localServers:[]}, {localServers:['ws://example.com:3088']}, {localServers:['ws://user:pass@localhost']}, {localServers:['ws://localhost/#fragment']}, {localServers:['ws://127.999.0.1']}, {localServers:['not-a-url']}]) {
for (const patch of [{debugIO:undefined}, {debugIO:'true'}, {mode:'other'}, {useLocalServer:undefined}, {gameserver:'ws://host'}, {gameserver:''}, {localServers:[]}, {localServers:['ws://example.com:3088']}, {localServers:['ws://user:pass@localhost']}, {localServers:['ws://localhost/#fragment']}, {localServers:['ws://127.999.0.1']}, {localServers:['not-a-url']}]) {
assert.throws(() => validateStartupConfig({...STARTUP_CONFIG,...patch} as any), /startup config/i);
}
for (const url of ['ws://127.4.3.2:3088','wss://localhost:3088','ws://[::1]:3088']) assert.deepEqual(validateStartupConfig({...STARTUP_CONFIG,localServers:[url]}).localServers,[url]);
@@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {runInNewContext} from 'node:vm';
import ts from 'typescript';
test('wire logs correctly label both directions and preserve transmitted frames',()=>{
for(const debugIO of [false,true]){
const logs:unknown[][]=[],sent:string[]=[],received:string[]=[];
let socket:any;
class WebSocket {
constructor(readonly url:string){socket=this;}
send(frame:string){sent.push(frame);}
}
const exports:any={};
const source=readFileSync(new URL('../../YouleNexus/assets/framework/net/cocos-transport.ts',import.meta.url),'utf8');
runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,
{exports,WebSocket,console:{log:(...args:unknown[])=>logs.push(args)},require:()=>({STARTUP_CONFIG:{debugIO}})});
const transport=new exports.CocosWebSocketTransport();transport.onMessage((s:string)=>received.push(s));transport.connect('ws://test');
const frame='{"route":"agent","rpc":"player_login","data":{"state":0}}';
transport.send(frame);socket.onmessage({data:frame});socket.onmessage({data:'{"com":"@serverheartbeat"}'});
assert.deepEqual(sent,[frame]);assert.equal(received[0],frame);
const frames=logs.filter(args=>String(args[0]).includes('包 '));
assert.equal(frames.length,debugIO?3:0);
if(debugIO){
assert.equal(frames[0][0],'[发包 C→S] agent/player_login');
assert.equal(frames[1][0],'[收包 S→C] agent/player_login');
assert.equal(frames[2][0],'[收包 S→C] @serverheartbeat');
assert.equal(typeof frames[0][2],'object');
}
else assert.equal(logs.length,0);
}
});
test('connection debug logs preserve close evidence and are disabled by debugIO',()=>{
for(const debugIO of [false,true]){
const logs:any[][]=[];let socket:any,opened=0,closed=0;
class WebSocket {
readyState=0;
constructor(readonly url:string){socket=this;}
close(){this.readyState=3;this.onclose({code:1006,reason:'test disconnect',wasClean:false});}
onclose:any;
}
const exports:any={};
const source=readFileSync(new URL('../../YouleNexus/assets/framework/net/cocos-transport.ts',import.meta.url),'utf8');
runInNewContext(ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText,
{exports,WebSocket,console:{log:(...args:any[])=>logs.push(args)},require:()=>({STARTUP_CONFIG:{debugIO}})});
const transport=new exports.CocosWebSocketTransport();
transport.onOpen(()=>opened++);transport.onClose(()=>closed++);transport.connect('ws://test');
socket.readyState=1;socket.onopen();socket.onerror({type:'error'});
assert.equal(opened,1);assert.equal(closed,1);
assert.deepEqual(logs.map(x=>x[0]),debugIO?['[WebSocket] 连接中','[WebSocket] 已连接','[WebSocket] 连接错误','[WebSocket] 已断开']:[]);
if(debugIO){assert.equal(logs[3][1].code,1006);assert.equal(logs[3][1].reason,'test disconnect');assert.equal(logs[3][1].readyState,3);}
}
});
@@ -20,6 +20,7 @@ test('login busy state starts Animation once, hides on completion and restarts o
const group = {active:false, paths:children};
let playing = false, starts = 0, stops = 0;
const panel = Object.create(exports.LoginPanel.prototype);
panel.modal = null;
panel.root = {paths:{'group-2/Logining':group}};
panel.loadingAnimation = {defaultClip:{name:'LoginLoading'}, getState:()=>({isPlaying:playing}),
play(name: string){assert.equal(name,'LoginLoading');playing=true;starts++;}, stop(){playing=false;stops++;}};
@@ -29,4 +30,8 @@ test('login busy state starts Animation once, hides on completion and restarts o
panel.setBusy(false);
assert.equal(stops,1); assert.equal(group.active,false); assert.equal(playing,false);
panel.setBusy(true); assert.equal(starts,2);
panel.modal = {name:'Layer29_PhoneInfo'};
panel.setBusy(true);
assert.equal(starts,2);assert.equal(group.active,false);assert.equal(playing,false);
assert.ok(Object.values(children).every(node=>!node.active));
});
@@ -0,0 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openLobbyUrl } from '../../YouleNexus/assets/framework/adapters/native/lobby-links.ts';
test('announcement links preserve fixUrl and distinct native timestamp conventions',()=>{
const calls:unknown[]=[];
openLobbyUrl('https://example.test/?a=1?b=2',{kind:'settings',browser:u=>calls.push(u)},()=>123);
openLobbyUrl('https://example.test/?a=1?b=2',{kind:'bridge',call:(n,d)=>calls.push([n,d])},()=>123);
assert.deepEqual(calls,['https://example.test/?a=1&b=2&timestamp=123',['browser','https://example.test/?a=1&b=2']]);
});
@@ -3,9 +3,32 @@ import assert from 'node:assert/strict';
import { LoginAccounts, nicknameHash } from '../../YouleNexus/assets/framework/adapters/native/login-accounts.ts';
import { resolveLoginPolicy } from '../../YouleNexus/assets/framework/presentation/login-policy.ts';
import { buildLoginRequest } from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.ts';
test('logout expires visitor auto-login and clears cached player selection',()=>{
const values=new Map<string,string>();
const service=new LoginAccounts({storage:{removeItem:(k:string)=>{values.delete(k);},getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:resolveLoginPolicy({raw:{},getValue:k=>k==='showVisitor'?1:null}),submit:()=>{},tip:()=>{}});
service.setAgreement(true);service.visitor();assert.ok(service.cached());
service.logout();assert.equal(service.cached(),null);
assert.equal(values.get('tsgame_playerid_ga'),'');
});
test('logout removes authorization caches and stays logged out after recreating accounts',()=>{
for(const mode of ['wechat','phone','visitor']){
const account=JSON.stringify({openid:'old',headimgurl:'a',nickname:'old',sex:1,city:'c',Province:'p',unionid:'old',telphone:'123'});
const values=new Map([['tsgame_wxinfo',account],['tsgame_devicePlayer',account],['tsgame_visitorinfo',account],
['tsgame_validtime','100000'],['tsgame_playerid_ga','42'],['tsgame_dLogin','1'],['tsgame_machineId_','installation']]);
const options={storage:{getItem:(k:string)=>values.get(k)??null,setItem:(k:string,v:string)=>{values.set(k,v);},removeItem:(k:string)=>{values.delete(k);}},
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),showVisitor:mode==='visitor',deviceLogin:mode==='phone'},submit:()=>{},tip:()=>{}};
const service=new LoginAccounts(options);assert.ok(service.cached());service.logout();service.logout();
assert.equal(values.has('tsgame_wxinfo'),false);assert.equal(values.has('tsgame_devicePlayer'),false);
assert.equal(values.get('tsgame_validtime'),'0');assert.equal(values.get('tsgame_dLogin'),'0');assert.equal(values.get('tsgame_playerid_ga'),'');
assert.equal(new LoginAccounts(options).cached(),null);
assert.equal(values.get('tsgame_visitorinfo'),account);assert.equal(values.get('tsgame_machineId_'),'installation');
}
});
test('native authorization preserves provider numbers, agreement guard, callback and cache', () => {
const values=new Map<string,string>();const calls:number[]=[];const submitted:any[]=[];const tips:string[]=[];
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
const service=new LoginAccounts({storage:{removeItem:(k:string)=>{values.delete(k);},getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
settings:{accreditlogin:p=>calls.push(p),getcompareCode:()=>0},random:()=>0.2,now:()=>100,
gameid:'g',agentid:'a',policy:resolveLoginPolicy({raw:{},getValue:()=>null}),submit:(a,d)=>submitted.push([a,d]),tip:t=>tips.push(t)});
service.setAgreement(false);service.authorize('wechat');assert.deepEqual(calls,[]);assert.deepEqual(tips,['未同意协议']);
@@ -16,7 +39,7 @@ test('native authorization preserves provider numbers, agreement guard, callback
});
test('all login paths require the current agreement and rejected paths have no side effects',()=>{
const values=new Map<string,string>(),tips:string[]=[],submitted:unknown[]=[],native:number[]=[];
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
const service=new LoginAccounts({storage:{removeItem:(k:string)=>{values.delete(k);},getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
settings:{getcompareCode:()=>0,accreditlogin:p=>native.push(p)},random:()=>0.2,now:()=>100,
gameid:'g',agentid:'a',policy:resolveLoginPolicy({raw:{},getValue:()=>null}),submit:a=>submitted.push(a),tip:t=>tips.push(t)});
assert.equal(service.agreed,true);service.setAgreement(false);
@@ -29,7 +52,7 @@ test('all login paths require the current agreement and rejected paths have no s
});
test('successful device login persists server identity for the next automatic login', () => {
const values=new Map<string,string>();const submitted:any[]=[];
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
const service=new LoginAccounts({storage:{removeItem:(k:string)=>{values.delete(k);},getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),deviceLogin:true},
submit:(a,d)=>submitted.push([a,d]),tip:()=>{}});
service.loginSucceeded({state:0,playerid:42,openid:'server-o',avatar:'server-a',nickname:'server-n',sex:2,city:'c',province:'p',unionid:'u',tel:'13800138000'}, {enabled:true,telphone:'13800138000'});
@@ -39,21 +62,40 @@ test('successful device login persists server identity for the next automatic lo
});
test('visitor login and cached visitor stay phone-free when both login buttons are enabled', () => {
const values=new Map<string,string>();const submitted:any[]=[];
const options={storage:{getItem:(k:string)=>values.get(k)??null,setItem:(k:string,v:string)=>{values.set(k,v);}},
const options={storage:{removeItem:(k:string)=>{values.delete(k);},getItem:(k:string)=>values.get(k)??null,setItem:(k:string,v:string)=>{values.set(k,v);}},
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),showVisitor:true,deviceLogin:true},
submit:(a:unknown,d:unknown)=>submitted.push([a,d]),tip:()=>{}};
const service=new LoginAccounts(options);
service.visitor();assert.deepEqual(submitted[0][1],{enabled:false});
assert.doesNotThrow(()=>service.loginSucceeded({state:0,playerid:42},submitted[0][1]));
const response={state:0,playerid:42,openid:'server',avatar:'a',nickname:'n',sex:0,city:'c',province:'p',unionid:'server'};
assert.doesNotThrow(()=>service.loginSucceeded(response,submitted[0][1]));
assert.equal(values.get('tsgame_playerid_ga'),'42');
assert.equal(values.has('tsgame_devicePlayer'),false);assert.equal(values.has('tsgame_dLogin'),false);
assert.equal(JSON.parse(values.get('tsgame_devicePlayer')!).sex,0);assert.equal(values.get('tsgame_dLogin'),'1');
const reloaded=new LoginAccounts(options);reloaded.autoLogin();
assert.deepEqual(submitted[1],[submitted[0][0],{enabled:false}]);
assert.doesNotThrow(()=>reloaded.loginSucceeded({state:0,playerid:42},submitted[1][1]));
assert.doesNotThrow(()=>reloaded.loginSucceeded(response,submitted[1][1]));
});
test('phone cache writes match legacy before submit, denial, and successful server response',()=>{
for(const showVisitor of [false,true])for(const deviceLogin of [false,true]){
const values=new Map<string,string>();let now=100;let submitted:any;
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);},removeItem:k=>{values.delete(k);}},
random:()=>0.2,now:()=>now,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),showVisitor,deviceLogin},
submit:(account,mode)=>{submitted={account,mode};assert.equal(values.has('tsgame_visitorinfo'),showVisitor);assert.equal(values.has('tsgame_devicePlayer'),false);},tip:()=>{}});
service.phone('00123','000456');
const before=[...values];assert.throws(()=>service.loginSucceeded({state:-1,msg:'短信验证码不正确'},submitted.mode));assert.deepEqual([...values],before);
now=200;
service.loginSucceeded({state:0,playerid:42,openid:'server',avatar:'avatar',nickname:'server name',sex:0,city:'city',province:'province',unionid:'server union',tel:'00123'},submitted.mode);
assert.equal(values.has('tsgame_devicePlayer'),deviceLogin);
if(deviceLogin){assert.deepEqual(JSON.parse(values.get('tsgame_devicePlayer')!),{openid:'server',headimgurl:'avatar',nickname:'server name',sex:0,city:'city',Province:'province',unionid:'server union',telphone:'00123'});assert.equal(values.get('tsgame_validtime'),String(200+48*60*60*1000));assert.equal(values.get('tsgame_dLogin'),'1');}
if(showVisitor)assert.equal(JSON.parse(values.get('tsgame_visitorinfo')!).openid,'phone_00123');
assert.equal(values.get('tsgame_playerid_ga'),'42');
assert.equal([...values.values()].some(v=>v.includes('000456')),false,'SMS code is not persisted');
}
});
test('global and WVJB authorization retain their distinct legacy cache destinations', () => {
const values=new Map<string,string>();
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
const service=new LoginAccounts({storage:{removeItem:(k:string)=>{values.delete(k);},getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),showVisitor:true},submit:()=>{},tip:()=>{}});
const data={openid:'o',headimgurl:'a',nickname:'n',sex:1,city:'c',Province:'p',unionid:'u'};
service.acceptNative(data,'global');assert.ok(values.has('tsgame_visitorinfo'));assert.equal(values.has('tsgame_wxinfo'),false);
@@ -63,9 +105,22 @@ test('legacy nickname MD5 matches standard vectors',()=>{
assert.equal(nicknameHash(''),'d41d8cd98f00b204e9800998ecf8427e');
assert.equal(nicknameHash('abc'),'900150983cd24fb0d6963f7d28e17f72');
});
test('phone login preserves leading zeros and legacy visitor cache before submitting', () => {
const values=new Map<string,string>();const tips:string[]=[];const submissions:unknown[]=[];
const service=new LoginAccounts({storage:{removeItem:(k:string)=>{values.delete(k);},getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:{...resolveLoginPolicy({raw:{},getValue:()=>null}),showVisitor:true},
submit:(account,mode)=>{assert.equal(JSON.parse(values.get('tsgame_visitorinfo')!).telphone,'00123');submissions.push([account,mode]);},tip:m=>tips.push(m)});
service.phone('','000456');service.phone('00123','');
assert.equal(submissions.length,0);assert.equal(values.size,0);
assert.deepEqual(tips,['手机号与验证码均不能为空!','手机号与验证码均不能为空!']);
service.phone('00123','000456');
assert.deepEqual((submissions[0] as unknown[])[1],{enabled:true,telphone:'00123',smmcode:'000456'});
assert.equal(values.has('tsgame_devicePlayer'),false);
});
test('phone login emits original sms fields and does not masquerade as cached auto login', () => {
const values=new Map<string,string>();const submitted:any[]=[];
const service=new LoginAccounts({storage:{getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
const service=new LoginAccounts({storage:{removeItem:(k:string)=>{values.delete(k);},getItem:k=>values.get(k)??null,setItem:(k,v)=>{values.set(k,v);}},
random:()=>0.2,now:()=>100,gameid:'g',agentid:'a',policy:resolveLoginPolicy({raw:{},getValue:()=>null}),
submit:(a,d)=>submitted.push([a,d]),tip:()=>{}});
service.phone('13800138000','123456');
@@ -10,10 +10,10 @@ const remote = (values: Record<string, unknown>) => ({raw:values,getValue:(key:s
test('source defaults and channel overrides retain legacy button order', () => {
const policy = resolveLoginPolicy(remote({}));
assert.deepEqual(loginButtons(policy, 0, false), {wechat:true,visitor:false,phone:false,quick:false,qq:false,account:false});
assert.deepEqual(loginButtons(policy, 1, false), {wechat:true,visitor:true,phone:false,quick:false,qq:false,account:false});
assert.deepEqual(loginButtons(policy, 1, false), {wechat:false,visitor:true,phone:false,quick:false,qq:false,account:false});
const configured = resolveLoginPolicy(remote({deviceLogin:1,hall_config:{showVisitor:1,qqLogin:1}}));
assert.deepEqual(loginButtons(configured, 0, false), {wechat:false,visitor:true,phone:true,quick:false,qq:true,account:false});
assert.deepEqual(loginButtons(configured, 0, true), {wechat:false,visitor:true,phone:false,quick:false,qq:true,account:true});
assert.deepEqual(loginButtons(configured, 0, false), {wechat:false,visitor:true,phone:false,quick:false,qq:false,account:false});
assert.deepEqual(loginButtons(configured, 0, true), {wechat:false,visitor:true,phone:false,quick:false,qq:false,account:true});
});
test('invalid upstream login options fail at the config source', () => {
assert.throws(()=>resolveLoginPolicy(remote({deviceLogin:'yes'})), /deviceLogin/);
@@ -34,7 +34,7 @@ test('build identity selects its own remote agent showVisitor through to login b
assert.equal(resolved.identity.channelid,'aouv0LotK0pYyQ0Pdrx0CsdcaezfzrcG');
const buttons=loginButtons(resolveLoginPolicy(resolved.remoteConfig),0,false);
assert.equal(buttons.visitor,true);assert.equal(buttons.wechat,false);
assert.equal(buttons.phone,true,'market deviceLogin and agent showVisitor both take effect');
assert.equal(buttons.phone,false,'visitor visibility takes precedence over deviceLogin');
const other=await resolveRuntimeConfig({...options,search:'?agentid=veRa0qrBf0df2K1G4de2tgfmVxB2jxpv'});
assert.equal(resolveLoginPolicy(other.remoteConfig).showVisitor,false,'must not read another agent configuration');
});
@@ -1,11 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { SceneFlow } from '../../YouleNexus/assets/scripts/platform-login/scene-flow.ts';
test('repeated lobby notifications share one pending transition and bind once',async()=>{
const calls:string[]=[];
const flow=new SceneFlow(async name=>{calls.push(name);});
await Promise.all([flow.enterOnce('Lobby',()=>{calls.push('bind');}),flow.enterOnce('Lobby',()=>{calls.push('duplicate');})]);
await flow.enterOnce('Lobby',()=>{calls.push('duplicate');});
assert.deepEqual(calls,['Lobby','bind']);
await flow.enter('Login',()=>{});
await flow.enterOnce('Lobby',()=>{calls.push('new');});
assert.deepEqual(calls,['Lobby','bind','Login','Lobby','new']);
});
test('scene transitions are sequential and disposal prevents late scene binding',async()=>{
const calls:string[]=[];let finish!:()=>void;
const flow=new SceneFlow(async name=>{calls.push(name);if(name==='Loading')await new Promise<void>(r=>finish=r);});
const loading=flow.enter('Loading',()=>calls.push('bindLoading'));
const login=flow.enter('Login',()=>calls.push('bindLogin'));
const loading=flow.enter('Loading',()=>{calls.push('bindLoading');});
const login=flow.enter('Login',()=>{calls.push('bindLogin');});
await Promise.resolve();assert.deepEqual(calls,['Loading']);finish();await loading;await login;
assert.deepEqual(calls,['Loading','bindLoading','Login','bindLogin']);
flow.dispose();await assert.rejects(flow.enter('Login',()=>{}),/disposed/);
@@ -42,6 +42,7 @@ test('applyLoginSuccess commits the complete login once and notifies once', () =
sex: 0,
bean: 0,
ip: '127.0.0.1',
roomcard: 3, taskstate: 1, score: 0, sign: null,
});
assert.deepEqual(state.room, { kind: 'outside' });
});
@@ -267,6 +267,7 @@ class RecordingScene implements ScenePort {
showRoom(): void { this.record('room'); }
showReconnect(): void { this.record('reconnect'); }
showKicked(data: unknown): void { this.record('kicked', data); }
showTips(message:string,time:number):void { this.record('tips',{message,time}); }
showServerDenial(outcome: unknown): void { this.record('denied', outcome); }
showFatal(error: Error): void { this.record('fatal', error); }
}
@@ -717,22 +718,27 @@ for (const count of [0, 2.5, 2]) {
});
}
test('a nonzero login outcome clears its guard, retains state and permits account retry', async () => {
for(const loginRoute of ['agent','room','platform'])test(`${loginRoute} login denial clears its guard, retains state and permits account retry`, async () => {
const { runtime, wire, scene, clock } = makeRuntime();
await runtime.start();
wire.emit({ type: 'open', server: 'ws://agent' });
const before = runtime.state;
runtime.login(ACCOUNT);
const denial = { state: 7, roomcode: 'unavailable', showerror: 1, error: 'Login denied' };
wire.emit({ type: 'message', message: { route: 'agent', rpc: 'player_login', data: denial } });
const denial = { state: -1, msg: '短信验证码不正确', time: 0 };
assert.equal(wire.sent.at(-1)?.route,'agent','outbound login remains agent');
wire.emit({ type: 'message', message: { route: loginRoute, rpc: 'player_login', data: denial } });
assert.equal(runtime.state, before);
assert.equal(wire.stopCalls, 0);
assert.equal(clock.timers.size, 0);
const outcome = scene.calls.find(call => call.name === 'denied')?.value;
assert.deepEqual(outcome, { rpc: 'player_login', state: 7, data: denial });
assert.deepEqual(outcome, { rpc: 'player_login', state: -1, data: denial });
assert.equal((outcome as { data: unknown }).data, denial);
assert.deepEqual(scene.calls.filter(call => call.name === 'fatal'), []);
const sentBeforeReconnect=wire.sent.length;
wire.emit({type:'reconnecting',server:'ws://agent'});
wire.emit({type:'open',server:'ws://agent'});
assert.equal(wire.sent.length,sentBeforeReconnect,'a denied first login must not be replayed');
runtime.login({ ...ACCOUNT, openid: 'retry-account' });
assert.equal((wire.sent[wire.sent.length - 1]!.data as Record<string, unknown>).openid, 'retry-account');
wire.emit({ type: 'message', message: {
@@ -1570,6 +1576,29 @@ test('slow and reconnecting show reconnect without clearing or replacing room st
);
});
test('kick_offline bypasses pending login and stops late navigation', async () => {
const {runtime,wire,scene}=makeRuntime();
await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const data={fromOther:true,gameid:'another-game'};
wire.emit({type:'message',message:{route:'agent',rpc:'kick_offline',data}});
assert.equal(scene.calls.at(-1)?.name,'kicked');assert.equal(scene.calls.at(-1)?.value,data);
assert.equal(wire.stopCalls,1);
const count=scene.calls.length;wire.emit({type:'open',server:'ws://agent'});
assert.equal(scene.calls.length,count);
});
test('show_message preserves text and legacy numeric timeout without navigating', async () => {
const {runtime,wire,scene}=makeRuntime();
await runtime.start();wire.emit({type:'open',server:'ws://agent'});
const before=runtime.state;
for(const time of ['3000','0','-1']){
wire.emit({type:'message',message:{route:'agent',rpc:'show_message',data:{msg:'服务器提示#第二行',time}}});
assert.deepEqual(scene.calls.at(-1),{name:'tips',value:{message:'服务器提示#第二行',time:Number(time)}});
assert.equal(runtime.state,before);
}
runtime.stop();
});
test('kick closes the game before showing kicked and stopping the wire', async () => {
const log: string[] = [];
const restored: unknown[] = [];
@@ -0,0 +1,25 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import { parseLoginResponse } from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.ts';
const response = () => JSON.parse(readFileSync(new URL('../fixtures/contracts/player-login-success.json', import.meta.url), 'utf8')).data;
test('login publishes server assets and task status, including subsequent login changes', () => {
const store = new PlatformStore();
const data = response();
store.applyLoginSuccess(parseLoginResponse(data));
const self = () => store.getState().players.entities[data.playerid] as any;
assert.equal(self().roomcard, 3);
assert.equal(self().taskstate, 1);
assert.equal(self().score, 0);
assert.equal(self().sign, null);
store.applyLoginSuccess(parseLoginResponse({...data, roomcard:8, taskstate:0}));
assert.equal(self().roomcard, 8);
assert.equal(self().taskstate, 0);
});
test('invalid supplied asset fields reject the complete login without publishing partial state', () => {
const store = new PlatformStore(), before = store.getState();
assert.throws(() => store.applyLoginSuccess(parseLoginResponse({...response(), roomcard:'3'})), /roomcard/);
assert.equal(store.getState(), before);
});
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRemoteConfig } from '../../YouleNexus/assets/framework/config/remote-config.ts';
import { resolveLobbyPolicy, shouldShowNotice, selectLobbyModel } from '../../YouleNexus/assets/framework/presentation/lobby-model.ts';
const identity = {agentid:'a',gameid:'g',channelid:'c',marketid:'m',version:1};
test('announcement interval preserves strict expiry and never shows on reconnect',()=>{
assert.equal(shouldShowNotice(0, null, 1000, false),true);
assert.equal(shouldShowNotice(-1, null, 1000, false),false);
assert.equal(shouldShowNotice(1, 1000, 3601000, false),false);
assert.equal(shouldShowNotice(1, 1000, 3601001, false),true);
assert.equal(shouldShowNotice(0, null, 1000, true),false);
});
test('notice configuration follows the same channel source and replaces legacy line breaks',()=>{
const p=resolveLobbyPolicy(createRemoteConfig({noticemsg:'global',agentlist:[{agentid:'a',menunotice:{advert0:{type:0,msg:'one#two',url:''}}}]},identity));
assert.equal(p.notices[0].text,'one\ntwo');
assert.equal(p.noticeHours,0);
assert.throws(()=>resolveLobbyPolicy(createRemoteConfig({gameNoticeShow:'bad'},identity)),/gameNoticeShow/);
});
test('lobby rejects incomplete self assets instead of displaying invented zeroes',()=>{
assert.throws(()=>selectLobbyModel({app:{phase:'logged-in'},room:{kind:'outside'},players:{selfPlayerId:7,entities:{7:{playerid:7,nickname:'A',avatar:'',sex:0,bean:0}}}}),/roomcard/);
});
@@ -18,6 +18,8 @@ test('FIRST_SLICE_RPC_ROUTE 是第一纵向切片唯一且精确的 rpc route
connect_roomserver: 'room',
connect_agentserver: 'agent',
kick_server: 'agent',
kick_offline: 'agent',
show_message: 'agent',
});
});
@@ -489,7 +489,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) continue;
if (route === expectedRoute || rpc==='player_login') continue;
assert.throws(
() => setup.router.dispatch({ route, rpc, data: {} }),
/route mismatch/i,
@@ -134,7 +134,7 @@ test('Router login gate enforces the complete route by rpc cross matrix', () =>
const message: InboundMessage = { route, rpc, data: { marker: `${route}:${rpc}` } };
gate.markLoginPending();
const passes = route === 'agent' && (rpc === 'player_login' || rpc === 'kick_server');
const passes = (rpc==='player_login'&&route!=='fixture-game')||(route === 'agent' && (rpc === 'kick_server' || rpc === 'kick_offline'));
assert.equal(
router.dispatch(message),
passes ? 'handled-platform' : 'ignored-login-gate',