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:
@@ -204,15 +204,18 @@ test('parseLoginResponse 拒绝负数、越界及非整数 seat', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('parseSelfJoinRoomResponse 接受金样,不深验 roomtype/deskinfo 并保留引用', () => {
|
||||
test('parseSelfJoinRoomResponse 接受可传输的 opaque 游戏字段并保留引用', () => {
|
||||
const data = fixture('self-join-room.json').data;
|
||||
const opaqueDeskinfo = new Date();
|
||||
const roomtype = [undefined, { gameDefined: true }];
|
||||
const parsed = parseSelfJoinRoomResponse({ ...data, roomtype, deskinfo: opaqueDeskinfo });
|
||||
const opaqueDeskinfo = { future: { round: 9, unknownCards: [17, 29] } };
|
||||
const roomtype = [null, { gameDefined: true, futureRule: ['unknown', 7] }];
|
||||
const unknownGameField = { nextVersion: ['kept', false] };
|
||||
const parsed = parseSelfJoinRoomResponse({ ...data, roomtype, deskinfo: opaqueDeskinfo, unknownGameField });
|
||||
assert.equal(parsed.state, 0);
|
||||
assert.equal(parsed.room?.roomtype, roomtype);
|
||||
assert.equal(parsed.reconnect.present, true);
|
||||
assert.equal(parsed.reconnect.present && parsed.reconnect.value, opaqueDeskinfo);
|
||||
assert.equal(parsed.raw.unknownGameField, unknownGameField);
|
||||
assert.throws(() => parseSelfJoinRoomResponse({ ...data, roomtype: [undefined, { gameDefined: true }] }), /\$\.data\.roomtype\.0.*finite JSON value/);
|
||||
});
|
||||
|
||||
test('parseSelfJoinRoomResponse defines the optional deskwar default once and deskinfo wins stage', () => {
|
||||
|
||||
@@ -6,7 +6,12 @@ import {
|
||||
} from '../../YouleNexus/assets/framework/protocol/first-slice-routes.ts';
|
||||
|
||||
test('FIRST_SLICE_RPC_ROUTE 是第一纵向切片唯一且精确的 rpc route 表', () => {
|
||||
assert.deepEqual(FIRST_SLICE_RPC_ROUTE, {
|
||||
assert.deepEqual(FIRST_SLICE_RPC_ROUTE, {
|
||||
broadcast: 'agent',
|
||||
beanroom_surrender:'room',send_text:'room',send_voice:'room',send_phiz:'room',self_break_room:'room',other_break_room:'room',change_room:'room',change_seat:'room',
|
||||
self_apply_free_room:'room',other_apply_free_room:'room',
|
||||
self_agree_free_room:'room',other_agree_free_room:'room',
|
||||
self_refuse_free_room:'room',other_refuse_free_room:'room',free_room:'room',
|
||||
getVipRankList: 'agent',
|
||||
get_treasurelist: 'agent',
|
||||
topup_card: 'agent',
|
||||
@@ -21,7 +26,10 @@ test('FIRST_SLICE_RPC_ROUTE 是第一纵向切片唯一且精确的 rpc route
|
||||
player_login: 'agent',
|
||||
create_room: 'agent',
|
||||
self_join_room: 'agent',
|
||||
player_prepare: 'room',
|
||||
player_prepare: 'room',
|
||||
// docs/protocol/03-协议-room路由.md; legacy Net → Desk → StartWar receives the full packet.
|
||||
self_makewar: 'room',
|
||||
other_makewar: 'room',
|
||||
self_exit_room: 'room',
|
||||
other_join_room: 'room',
|
||||
other_exit_room: 'room',
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {requireRoomtype} from '../../YouleNexus/assets/framework/protocol/contracts/validation.ts';
|
||||
import {cloneAndFreezeRoomtype} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||
test('opaque JSON roomtype preserves all finite scalar and container formats',()=>{
|
||||
for(const value of [null,false,true,0,1.25,'001010',[1,{unknown:[null,true]}],{mode:'0001',options:[3,2,1]}]){assert.equal(requireRoomtype(value,'roomtype'),value);assert.deepEqual(cloneAndFreezeRoomtype(value),value);}
|
||||
});
|
||||
test('opaque JSON transport rejects unsupported values without executing accessors',()=>{
|
||||
const cycle:any={};cycle.self=cycle;const getter=Object.defineProperty({},'x',{enumerable:true,get(){throw Error('executed');}});
|
||||
for(const value of [undefined,NaN,Infinity,1n,()=>1,cycle,new Date(),[undefined],new Array(2),getter]){assert.throws(()=>requireRoomtype(value,'roomtype'),TypeError);assert.throws(()=>cloneAndFreezeRoomtype(value),TypeError);}
|
||||
});
|
||||
@@ -96,7 +96,7 @@ function makeHandlers() {
|
||||
}),
|
||||
});
|
||||
const connection = new ConnectionFixture();
|
||||
const handlers = new PlatformHandlers({ store, gameSession, session, connection });
|
||||
const handlers = new PlatformHandlers({ store, gameSession, session, connection,showMessage(){} });
|
||||
const router = new Router({ session, platform: handlers, gameSession });
|
||||
return {
|
||||
store,
|
||||
@@ -183,6 +183,7 @@ test('Router drains reentrant room entries FIFO so each module receives its own
|
||||
});
|
||||
const roomA = fixture('self-join-room.json');
|
||||
const roomB = fixture('self-join-room.json');
|
||||
roomA.deskwar=0;roomB.deskwar=0;
|
||||
roomA.roomcode = 'room-A';
|
||||
roomB.roomcode = 'room-B';
|
||||
roomA.deskinfo = { round: 'A' };
|
||||
@@ -285,6 +286,7 @@ test('PlatformHandlers commits every room event before one typed game event read
|
||||
let received = 0;
|
||||
const unsubscribe = setup.store.subscribe(() => { order.push('store'); });
|
||||
setup.observeEvent((event, snapshot) => {
|
||||
if(event.type==='room.started'){assertSnapshot(snapshot);return;}
|
||||
received += 1;
|
||||
order.push('game');
|
||||
assert.equal(event.type, expectedType);
|
||||
@@ -405,7 +407,7 @@ test('PlatformHandlers preserves complete switch payloads and builds the exact r
|
||||
const setup = makeHandlers();
|
||||
const roomData = { roomserver: '10.0.0.2:3088', epoch: 7 };
|
||||
const agentData = {
|
||||
agentserver: 'secure.example:443/path', opt: 'free_room', trace: { id: 'x' },
|
||||
agentserver: 'secure.example:443/path', opt: 'self_exit_room', trace: { id: 'x' },
|
||||
};
|
||||
|
||||
assert.equal(setup.router.dispatch({
|
||||
@@ -489,7 +491,7 @@ test('unknown and every route-mismatched platform rpc leave all observable state
|
||||
}
|
||||
for (const [rpc, expectedRoute] of rpcsByRoute) {
|
||||
for (const route of ['platform', 'agent', 'room'] as const) {
|
||||
if (route === expectedRoute || rpc==='player_login') continue;
|
||||
if (route === expectedRoute || rpc==='player_login'||rpc==='connect_agentserver'&&route==='room') continue;
|
||||
assert.throws(
|
||||
() => setup.router.dispatch({ route, rpc, data: {} }),
|
||||
/route mismatch/i,
|
||||
@@ -555,7 +557,7 @@ test('PlatformHandlers accepts case-insensitive WS/WSS schemes and preserves ful
|
||||
extra: { trace: 1 },
|
||||
};
|
||||
const agentData = {
|
||||
agentserver: 'WSS://secure.example:443/socket', opt: 'free_room', extra: { trace: 2 },
|
||||
agentserver: 'WSS://secure.example:443/socket', opt: 'self_exit_room', extra: { trace: 2 },
|
||||
};
|
||||
|
||||
setup.router.dispatch({ route: 'room', rpc: 'connect_roomserver', data: roomData });
|
||||
@@ -675,3 +677,38 @@ test('kick_server attempts dispose, invalidate, mark, and stop once then aggrega
|
||||
assert.equal(setup.getInvalidations(), 1);
|
||||
assert.equal(setup.connection.stopCalls.length, 1);
|
||||
});
|
||||
|
||||
test('active dispatcher receives dissolution and publishes immediate settlement contract without disposal',()=>{
|
||||
const fixture=makeHandlers();fixture.session.markLoginPending();fixture.handlers.dispatch({route:'agent',rpc:'player_login',data:fixtureData()});
|
||||
const events:PlatformToGameEvent[]=[];fixture.observeEvent(event=>events.push(event));
|
||||
fixture.handlers.dispatch({route:'room',rpc:'self_apply_free_room',data:{agreefree:{state:[null,0,null,null],countdown:60}}});
|
||||
assert.equal(fixture.store.getState().dissolution?.kind,'active');
|
||||
fixture.handlers.dispatch({route:'room',rpc:'free_room',data:{freeNow:1,deskfree:{round:4},tips:'解散'}});
|
||||
assert.deepEqual(events,[{type:'room.dissolution-confirmed',freeNow:true,deskfree:{round:4},tips:'解散'}]);
|
||||
assert.equal(fixture.gameSession.state,'active');assert.equal(fixture.getDisposals(),0);
|
||||
function fixtureData(){return JSON.parse(readFileSync(new URL('../fixtures/contracts/player-login-room.json',import.meta.url),'utf8')).data;}
|
||||
});
|
||||
|
||||
test('room social ingress and confirmed break traverse Router and clear game ownership once',()=>{
|
||||
const setup=makeHandlers();setup.router.dispatch({route:'agent',rpc:'player_login',data:fixture('player-login-room.json')});
|
||||
setup.router.dispatch({route:'room',rpc:'send_text',data:{seat:1,text:'network',type:0}});
|
||||
assert.equal(setup.store.getState().roomSocial?.history[0].text,'network');
|
||||
setup.router.dispatch({route:'room',rpc:'change_seat',data:{seat1:1,seat2:0}});
|
||||
const room=setup.store.getState().room;assert.ok(room.kind==='inside');assert.equal(room.selfSeat,0);
|
||||
const before=setup.getDisposals();setup.router.dispatch({route:'room',rpc:'self_break_room',data:{roomcode:'100001'}});
|
||||
assert.equal(setup.store.getState().room.kind,'outside');assert.equal(setup.store.getState().roomSocial,undefined);assert.equal(setup.getDisposals(),before+1);
|
||||
setup.router.dispatch({route:'room',rpc:'self_break_room',data:{}});assert.equal(setup.getDisposals(),before+1);
|
||||
});
|
||||
|
||||
test('all wire start paths preserve the original complete envelope after committed public state',()=>{
|
||||
for(const rpc of ['self_makewar','other_makewar','player_prepare','other_join_room','self_join_room'] as const){
|
||||
const setup=makeHandlers();const login=fixture('player-login-room.json');const players=login.players as any[];players[0]={...players[1],playerid:99,isprepare:0,onstate:0};players[1].isprepare=0;setup.session.handleLogin(login);
|
||||
const events:PlatformToGameEvent[]=[];setup.observeEvent((event,snapshot)=>{events.push(event);if(event.type==='room.started'){assert.equal(snapshot.room.stage,1);if(rpc==='player_prepare')assert.equal(snapshot.seats.find(s=>s.seat===1)?.ready,true);}});
|
||||
const data=rpc==='self_join_room'?{...fixture('self-join-room.json'),deskwar:1}:rpc==='other_join_room'?{seat:2,playerid:77,nickname:'new',avatar:'',sex:1,ip:'127.0.0.2',onstate:0,bean:90,isprepare:0,deskwar:1}:rpc==='player_prepare'?{seat:1,deskwar:1}:{unknownGameField:{cards:[1,2]}};
|
||||
const message={route:rpc==='self_join_room'?'agent':'room',rpc,data,unknownEnvelopeField:42};
|
||||
setup.handlers.dispatch(message);
|
||||
const started=events.filter(e=>e.type==='room.started');assert.equal(started.length,1);assert.equal(started[0].sourceRpc,rpc);assert.equal(started[0].message,message);
|
||||
if(rpc==='player_prepare')assert.deepEqual(events.map(e=>e.type),['room.player-ready','room.started']);
|
||||
if(rpc==='other_join_room')assert.deepEqual(events.map(e=>e.type),['room.player-joined','room.started']);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {parseCreateRoomResponse,parseSelfJoinRoomResponse} from '../../YouleNexus/assets/framework/protocol/contracts/room-contracts.ts';
|
||||
import {parseLoginResponse} from '../../YouleNexus/assets/framework/protocol/contracts/login-contract.ts';
|
||||
import {PlatformStore} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
|
||||
|
||||
const fixture=(name:string)=>JSON.parse(readFileSync(new URL('../fixtures/contracts/'+name,import.meta.url),'utf8')).data;
|
||||
test('Store verifies numeric raw identity without permitting forged parsed room IDs',()=>{
|
||||
const store=new PlatformStore();store.applyLoginSuccess(parseLoginResponse(fixture('player-login-success.json')));
|
||||
const before=store.getState();
|
||||
const parsed=parseSelfJoinRoomResponse({...fixture('self-join-room.json'),roomcode:123456});
|
||||
assert.ok(parsed.room);
|
||||
const room=parsed.room;
|
||||
assert.throws(()=>store.replaceRoom({...parsed,room:{...room,roomcode:'different'}}),/roomcode/);
|
||||
assert.equal(store.getState(),before);
|
||||
store.replaceRoom(parsed);
|
||||
assert.equal(store.getState().room.kind,'inside');
|
||||
});
|
||||
const cases=[
|
||||
{name:'create',parse:parseCreateRoomResponse,data:()=>({state:0,seat:0,roomtype:'11011000000'})},
|
||||
{name:'join',parse:parseSelfJoinRoomResponse,data:()=>fixture('self-join-room.json')},
|
||||
{name:'login restore',parse:parseLoginResponse,data:()=>fixture('player-login-room.json')},
|
||||
];
|
||||
for(const entry of cases){
|
||||
test(entry.name+' normalizes numeric room codes without changing wire data or string identity',()=>{
|
||||
for(const roomcode of [123456,'123456','001234']){
|
||||
const data={...entry.data(),roomcode};const parsed=entry.parse(data);
|
||||
assert.equal(parsed.room?.roomcode,String(roomcode));
|
||||
assert.equal(parsed.raw,data);assert.equal(data.roomcode,roomcode);
|
||||
}
|
||||
});
|
||||
test(entry.name+' rejects invalid numeric room codes and unrelated types at ingress',()=>{
|
||||
for(const roomcode of [NaN,Infinity,-1,1.5,Number.MAX_SAFE_INTEGER+1,null,true,{},[]]){
|
||||
assert.throws(()=>entry.parse({...entry.data(),roomcode}),/roomcode/);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user