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:
@@ -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