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}); import { remoteFixture } from '../fixtures/remote-config.ts'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts'; import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts'; import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts'; import { parseLoginResponse, } from '../../YouleNexus/assets/framework/protocol/contracts/index.ts'; const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({ startupConfig: {...STARTUP_CONFIG,mode:'release' as const,useLocalServer:false}, mode: 'release', isDebugger: false, identity: Object.freeze({ agentid: 'agent-from-config', channelid: 'channel-from-config', gameid: 'game-from-config', marketid: 'market-from-config', version: 41, }), servers: Object.freeze(['ws://fixture']), source: 'remote', gameserver: 'https://fixture/config', ...remoteFixture(), }); function fixture(name: string): { data: Record } { return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8')); } function makeStore(location: 'outside' | 'inside', stage = 0, infinite = 0): PlatformStore { const store = new PlatformStore(); const source = fixture(location === 'inside' ? 'player-login-room.json' : 'player-login-success.json'); if (location === 'inside') { source.data.isbattle = stage; source.data.infinite = infinite; } store.applyLoginSuccess(parseLoginResponse(source.data)); return store; } function makeCommands(store: PlatformStore): { readonly commands: PlatformCommands; readonly sent: OutboundEnvelope[]; } { const sent: OutboundEnvelope[] = []; return { commands: new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send: { send: (envelope) => { sent.push(envelope); } }, }), 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',()=>{ 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('task requests preserve legacy identity fields and reject unclaimable entries',()=>{ const store=makeStore('outside');const {commands,sent}=makeCommands(store); commands.getPlayerTasks(); store.applyTaskList({tasks:[{taskid:7,title:'任务',award:1,finish:1,total:1,state:1}]}); commands.claimTask(7); assert.deepEqual(sent,[ {app:'youle',route:'agent',rpc:'get_player_task',data:{agentid:'agent-from-config',playerid:430511,gameid:'game-from-config'}}, {app:'youle',route:'agent',rpc:'get_task_award',data:{agentid:'agent-from-config',playerid:430511,taskid:7}}, ]); store.applyTaskAward({taskid:7,taskstate:0}); assert.equal(store.getState().tasks?.[0].state,2); assert.equal(store.getState().players.entities[430511].taskstate,0); assert.throws(()=>commands.claimTask(7),/claimable/); const before=store.getState(); assert.throws(()=>store.applyTaskList({tasks:[{taskid:7}]})); assert.equal(store.getState(),before); assert.throws(()=>makeCommands(new PlatformStore()).commands.getPlayerTasks(),/logged-in/); }); test('task numeric strings normalize at ingestion and invalid values never commit',()=>{ const store=makeStore('outside'); const task={taskid:'7',title:'任务',award:'3',finish:'2',total:'5',state:'0'}; store.applyTaskList({tasks:[task]}); assert.deepEqual(store.getState().tasks,[{...task,award:3,finish:2,total:5,state:0}]); assert.equal(task.award,'3'); const before=store.getState(); for(const field of ['award','finish','total','state']){ for(const value of ['', ' ', 'abc', 'Infinity', null, true, [], {}, undefined]){ assert.throws(()=>store.applyTaskList({tasks:[{...task,[field]:value}]}),new RegExp(field)); assert.equal(store.getState(),before); } } assert.throws(()=>store.applyTaskList({tasks:[{...task,state:'1.5'}]}),/state/); }); test('prepare and exitRoom use the first-slice builders with authoritative config and room state', () => { const { commands, sent } = makeCommands(makeStore('inside', 0)); commands.prepare(); commands.exitRoom(); assert.deepEqual(sent, [ { app: 'youle', route: 'room', rpc: 'player_prepare', data: { agentid: 'agent-from-config', playerid: 430511, gameid: 'game-from-config', roomcode: '100001', }, }, { app: 'youle', route: 'room', rpc: 'self_exit_room', data: { agentid: 'agent-from-config', playerid: 430511, gameid: 'game-from-config', roomcode: '100001', }, }, ]); assert.equal(commands.prepare.length, 0); assert.equal(commands.exitRoom.length, 0); }); test('joinRoom uses authenticated Store identity and requires every non-optional environment field', () => { const { commands, sent } = makeCommands(makeStore('outside')); const location = { latitude: 28.7, longitude: 115.8 }; commands.joinRoom({ roomcode: '654321', location, ip: '203.0.113.7', vipMatch: 1, match_id: 'match-9', }); assert.deepEqual(sent, [{ app: 'youle', route: 'agent', rpc: 'self_join_room', data: { agentid: 'agent-from-config', playerid: 430511, gameid: 'game-from-config', roomcode: '654321', location, ip: '203.0.113.7', vipMatch: 1, match_id: 'match-9', }, }]); assert.equal((sent[0]!.data as { location: unknown }).location, location); assert.equal(commands.joinRoom.length, 1); assert.throws( () => commands.joinRoom({ roomcode: '654321', ip: '203.0.113.7' } as never), /location/i, ); assert.throws( () => commands.joinRoom({ roomcode: '654321', location } as never), /ip/i, ); assert.equal(sent.length, 1); }); test('exitRoom sends self_exit_room while an infinite room is in battle', () => { const { commands, sent } = makeCommands(makeStore('inside', 1, 1)); commands.exitRoom(); assert.deepEqual(sent, [{ app: 'youle', route: 'room', rpc: 'self_exit_room', data: { agentid: 'agent-from-config', playerid: 430511, gameid: 'game-from-config', roomcode: '100001', }, }]); }); test('command preconditions reject missing authentication, wrong room location, and battle exit without sending', () => { const unauthenticated = makeCommands(new PlatformStore()); assert.throws( () => unauthenticated.commands.joinRoom({ roomcode: '654321', location: null, ip: '' }), /authenticated|player/i, ); const outside = makeCommands(makeStore('outside')); assert.throws(() => outside.commands.prepare(), /inside.*room|room.*inside/i); assert.throws(() => outside.commands.exitRoom(), /inside.*room|room.*inside/i); const inside = makeCommands(makeStore('inside', 0)); assert.throws( () => inside.commands.joinRoom({ roomcode: '654321', location: null, ip: '' }), /outside.*room|already.*room/i, ); const battle = makeCommands(makeStore('inside', 1)); assert.throws(() => battle.commands.exitRoom(), /room\.exit.*stage=0/i); assert.equal(unauthenticated.sent.length, 0); assert.equal(outside.sent.length, 0); 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] 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[];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'); });