feat: integrate login flow, lobby UI and bitmap font resources
This commit is contained in:
@@ -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×tamp=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',
|
||||
|
||||
Reference in New Issue
Block a user