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:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user