Files
joywayer 35216f75e7 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.
2026-09-09 03:13:18 +08:00

598 lines
26 KiB
TypeScript

import { STARTUP_CONFIG as EDITABLE_STARTUP_CONFIG } from '../../YouleNexus/assets/framework/config/profiles.ts';
// 固定测试来源,允许开发者独立切换项目的 local/remote 开关。
const STARTUP_CONFIG = Object.freeze({...EDITABLE_STARTUP_CONFIG, configSource:'remote' as const});
import { remoteFixture } from '../fixtures/remote-config.ts';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { test } from 'node:test';
import type { RuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts';
import type { OutboundEnvelope } from '../../YouleNexus/assets/framework/core/types/envelope.ts';
import {
createGameHostAdapter,
GameSessionDisposedError,
} from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts';
import { PlatformCommands } from '../../YouleNexus/assets/framework/platform/commands.ts';
import { RuntimeSession } from '../../YouleNexus/assets/framework/platform/runtime-session.ts';
import { PlatformStore } from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import type {
PlatformState,
} from '../../YouleNexus/assets/framework/platform/stores/platform-types.ts';
import {
parseLoginResponse,
parseOtherJoinRoomPayload,
parsePlayerPreparePayload,
} from '../../YouleNexus/assets/framework/protocol/contracts/index.ts';
import type {
GameEntry,
GameHost,
GameHostCommand,
} from '../../YouleNexus/assets/framework/sdk/contracts/index.ts';
import {
assertGameRoute,
GameSessionHost,
} from '../../YouleNexus/assets/framework/sdk/runtime/game-session-host.ts';
const RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
startupConfig: {...STARTUP_CONFIG,mode:'release' as const,useLocalServer:false}, mode: 'release',
isDebugger: false,
identity: Object.freeze({
agentid: 17,
channelid: 18,
gameid: 'game-41',
marketid: 19,
version: 41,
}),
servers: Object.freeze(['ws://fixture']),
source: 'remote',
gameserver: 'https://fixture/config',
...remoteFixture(),
});
function fixture(name: string): { data: Record<string, unknown> } {
return JSON.parse(readFileSync(new URL(`../fixtures/contracts/${name}`, import.meta.url), 'utf8'));
}
function roomStore(seatCount = 4, selfSeat = 1, stage = 0): PlatformStore {
const source = fixture('player-login-room.json');
const self = (source.data.players as Array<Record<string, unknown> | null>)[1]!;
const players = Array.from({ length: seatCount }, () => null) as Array<Record<string, unknown> | null>;
players[selfSeat] = { ...self };
source.data.players = players;
source.data.seat = selfSeat;
source.data.roomtype = [seatCount, { nested: [1, 2] }];
source.data.isbattle = stage;
const store = new PlatformStore();
store.applyLoginSuccess(parseLoginResponse(source.data));
return store;
}
function entry(route = 'fixture-route'): GameEntry {
return {
key: 'fixture',
gameId: 'game-41',
route,
resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;},
createModule: () => ({
attach() {}, handlePlatformEvent() {}, handleGameMessage() {}, restore() {}, dispose() {},
}),
};
}
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[] = [];
const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } };
const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send });
const lease = createGameHostAdapter({
gameEntry: options.gameEntry ?? entry(),
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 };
host.sendGameMessage('play-card', data);
assert.deepEqual(sent[0], {
app: 'youle', route: 'fixture-route', rpc: 'play-card', data: { card: 7 },
});
assert.equal(sent[0]!.data, data);
assert.equal(host.sendGameMessage.length, 2, 'Host API 不接受 route');
assert.throws(() => host.sendGameMessage('', {}), /rpc.*non-empty/i);
assert.throws(() => host.sendGameMessage(' ', {}), /rpc.*non-empty/i);
assert.equal(sent.length, 1);
});
test('adapter rejects empty and reserved GameEntry routes before exposing a Host', () => {
for (const route of ['', ' ', 'platform', 'agent', 'room']) {
assert.throws(
() => makeLease({ gameEntry: entry(route) }),
route.trim() === '' ? /route.*non-empty/i : /reserved.*route/i,
);
}
});
test('Task 1 route validator is the shared authority for game-route admission', () => {
assert.doesNotThrow(() => assertGameRoute('fixture-route'));
assert.throws(() => assertGameRoute(''), /route.*non-empty/i);
for (const route of ['platform', 'agent', 'room']) {
assert.throws(() => assertGameRoute(route), /reserved.*route/i);
}
});
test('execute exposes only the GameHost command union and delegates semantic commands', () => {
const { host, sent } = makeLease({ store: roomStore(4, 1, 0) });
host.execute({ type: 'room.prepare' });
host.execute({ type: 'room.exit' });
assert.equal(sent[0]!.rpc, 'player_prepare');
assert.equal(sent[1]!.rpc, 'self_exit_room');
assert.throws(
() => host.execute({ type: 'room.join' } as unknown as GameHostCommand),
/unsupported.*command/i,
);
assert.equal(sent.length, 2);
});
test('seat mapper binds each 2, 4, and 10 seat session and round-trips every server seat', () => {
for (const seatCount of [2, 4, 10]) {
const selfSeat = seatCount - 1;
const { host } = makeLease({ store: roomStore(seatCount, selfSeat) });
assert.equal(host.seat.toView(selfSeat), 0);
for (let serverSeat = 0; serverSeat < seatCount; serverSeat++) {
const viewSeat = (serverSeat - selfSeat + seatCount) % seatCount;
assert.equal(host.seat.toView(serverSeat), viewSeat);
assert.equal(host.seat.toServer(viewSeat), serverSeat);
}
for (const invalid of [-1, seatCount, 0.5, Number.NaN, Number.POSITIVE_INFINITY, '0']) {
assert.throws(() => host.seat.toView(invalid as number), /seat.*range|integer/i);
assert.throws(() => host.seat.toServer(invalid as number), /seat.*range|integer/i);
}
}
});
test('adapter validates descriptor seat count against the canonical room session', () => {
const store = roomStore(4, 1);
for (const seatCount of [0, -1, 2.5, Number.NaN, 2]) {
assert.throws(
() => makeLease({
store,
gameEntry: { ...entry(), resolveSeatCount: () => seatCount },
}),
/seat count/i,
);
}
});
test('getSnapshot exposes only recursively frozen SDK DTOs and the canonical roomtype', () => {
const { host, store } = makeLease();
const state = store.getState();
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
const snapshot = host.getSnapshot();
assert.deepEqual(Object.keys(snapshot).sort(), ['connection', 'room', 'seats', 'self']);
assert.equal(snapshot.room.roomtype, state.room.roomtype);
assert.equal(Object.isFrozen(snapshot), true);
assert.equal(Object.isFrozen(snapshot.connection), true);
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);
assert.equal(Object.isFrozen(snapshot.seats[0]), true);
assert.equal('getState' in snapshot, false);
assert.equal('subscribe' in snapshot, false);
assert.equal('state' in snapshot, false);
});
test('RuntimeSession room replacement keeps the old lease isolated until release and exposes only the fresh lease', () => {
const store = new PlatformStore();
const sent: OutboundEnvelope[] = [];
const send = { send: (envelope: OutboundEnvelope) => { sent.push(envelope); } };
const commands = new PlatformCommands({ runtimeConfig: RUNTIME_CONFIG, store, send });
const attachedHosts: GameHost[] = [];
const staleDisposeResults: unknown[] = [];
const gameEntry: GameEntry = {
...entry(),
resolveSeatCount: (roomtype) => {assert.ok(Array.isArray(roomtype));return roomtype[0] as number;},
createModule: () => {
let host: GameHost | null = null;
return {
attach(value) {
host = value;
attachedHosts.push(value);
},
handlePlatformEvent() {},
handleGameMessage() {},
restore() {},
dispose() {
if (host === null) return;
for (const call of [
() => host!.getSnapshot(),
() => host!.sendGameMessage('stale-play', { room: 'old' }),
() => host!.execute({ type: 'room.prepare' }),
]) {
try {
staleDisposeResults.push(call());
} catch (error) {
staleDisposeResults.push(error);
}
}
},
};
},
};
const gameSession = new GameSessionHost(gameEntry);
const runtime = new RuntimeSession({
store,
gameSession,
onServerDenial() { assert.fail('Unexpected server denial'); },
createGameHostLease: () => createGameHostAdapter({ gameEntry, store, commands, send }),
});
const login = fixture('player-login-room.json');
login.data.roomtype = [4, { generation: 'A' }];
login.data.isbattle = 0;
runtime.handleLogin(login.data);
const oldHost = attachedHosts[0]!;
const oldRoomcodes: string[] = [];
oldHost.subscribe((snapshot) => { oldRoomcodes.push(snapshot.room.roomcode); });
const replacement = fixture('self-join-room.json');
replacement.data.roomcode = '200002';
replacement.data.roomtype = [4, { generation: 'B' }];
replacement.data.deskwar = 0;
delete replacement.data.deskinfo;
runtime.handleSelfJoin(replacement.data);
assert.deepEqual(oldRoomcodes, ['100001']);
assert.equal(staleDisposeResults.length, 3);
for (const result of staleDisposeResults) {
assert.ok(result instanceof GameSessionDisposedError);
}
assert.deepEqual(sent, []);
assert.equal(attachedHosts.length, 2);
assert.equal(attachedHosts[1]!.getSnapshot().room.roomcode, '200002');
});
class ObservableStore extends PlatformStore {
private readonly observed = new Set<(state: PlatformState, previous: PlatformState) => void>();
override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void {
const unsubscribeStore = super.subscribe(listener);
this.observed.add(listener);
return () => {
this.observed.delete(listener);
unsubscribeStore();
};
}
emitCurrent(): void {
const state = this.getState();
for (const listener of [...this.observed]) listener(state, state);
}
}
test('subscribe is immediate, suppresses identical public snapshots, and isolates listener failures', () => {
const store = new ObservableStore();
const source = fixture('player-login-room.json');
source.data.isbattle = 0;
source.data.roomtype = [4];
store.applyLoginSuccess(parseLoginResponse(source.data));
const { host } = makeLease({ store });
const received: unknown[] = [];
host.subscribe((snapshot) => { received.push(snapshot); });
store.emitCurrent();
assert.equal(received.length, 1);
const listenerFailure = new Error('listener failed');
const reports: unknown[][] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => { reports.push(args); };
try {
assert.throws(
() => host.subscribe(() => { throw listenerFailure; }),
(error) => error === listenerFailure,
);
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
assert.equal(received.length, 2);
assert.equal(reports.length, 0, 'failed initial subscriptions must be removed');
} finally {
console.error = originalConsoleError;
}
});
test('a later listener failure does not block other Host subscribers', () => {
const { host, store } = makeLease();
const listenerFailure = new Error('later listener failed');
let firstCalls = 0;
let secondCalls = 0;
const reports: unknown[][] = [];
const originalConsoleError = console.error;
console.error = (...args: unknown[]) => { reports.push(args); };
try {
host.subscribe(() => {
firstCalls++;
if (firstCalls > 1) throw listenerFailure;
});
host.subscribe(() => { secondCalls++; });
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
assert.equal(firstCalls, 2);
assert.equal(secondCalls, 2);
assert.equal(reports.length, 1);
assert.equal(reports[0]!.includes(listenerFailure), true);
} finally {
console.error = originalConsoleError;
}
});
class ThrowingUnsubscribeStore extends PlatformStore {
unsubscribeCalls = 0;
override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void {
const unsubscribeStore = super.subscribe(listener);
return () => {
unsubscribeStore();
this.unsubscribeCalls++;
if (this.unsubscribeCalls === 1) throw undefined;
throw null;
};
}
}
test('invalidate clears every subscription and reference before reporting all cleanup failures', () => {
const store = new ThrowingUnsubscribeStore();
const source = fixture('player-login-room.json');
source.data.isbattle = 0;
source.data.roomtype = [4];
store.applyLoginSuccess(parseLoginResponse(source.data));
const { host, invalidate } = makeLease({ store });
let callbacks = 0;
const firstUnsubscribe = host.subscribe(() => { callbacks++; });
const secondUnsubscribe = host.subscribe(() => { callbacks++; });
let cleanupError: unknown;
try {
invalidate();
} catch (error) {
cleanupError = error;
}
assert.equal(store.unsubscribeCalls, 2);
assert.deepEqual(
(cleanupError as { errors?: readonly unknown[] })?.errors,
[undefined, null],
);
assert.throws(() => host.getSnapshot(), GameSessionDisposedError);
assert.doesNotThrow(() => firstUnsubscribe());
assert.doesNotThrow(() => secondUnsubscribe());
assert.doesNotThrow(() => invalidate());
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
assert.equal(callbacks, 2);
});
class ReentrantThrowingUnsubscribeStore extends PlatformStore {
readonly nestedFailure = { source: 'subscription-B' };
readonly cleanupCalls: string[] = [];
secondPublicUnsubscribe: (() => void) | null = null;
private subscriptionIndex = 0;
override subscribe(listener: (state: PlatformState, previous: PlatformState) => void): () => void {
const index = this.subscriptionIndex++;
const unsubscribeStore = super.subscribe(listener);
if (index === 0) {
return () => {
this.cleanupCalls.push('A');
this.secondPublicUnsubscribe!();
unsubscribeStore();
throw undefined;
};
}
return () => {
this.cleanupCalls.push('B');
unsubscribeStore();
throw this.nestedFailure;
};
}
}
test('invalidate collects a reentrant public-unsubscribe failure in occurrence order exactly once', () => {
const store = new ReentrantThrowingUnsubscribeStore();
const source = fixture('player-login-room.json');
source.data.isbattle = 0;
source.data.roomtype = [4];
store.applyLoginSuccess(parseLoginResponse(source.data));
const { host, invalidate } = makeLease({ store });
const firstPublicUnsubscribe = host.subscribe(() => {});
const secondPublicUnsubscribe = host.subscribe(() => {});
store.secondPublicUnsubscribe = secondPublicUnsubscribe;
let didThrow = false;
let cleanupError: unknown;
try {
invalidate();
} catch (error) {
didThrow = true;
cleanupError = error;
}
assert.equal(didThrow, true);
assert.deepEqual(store.cleanupCalls, ['A', 'B']);
const errors = (cleanupError as { errors?: readonly unknown[] })?.errors;
assert.equal(errors?.[0], store.nestedFailure);
assert.equal(errors?.[1], undefined);
assert.equal(errors?.length, 2);
assert.doesNotThrow(() => firstPublicUnsubscribe());
assert.doesNotThrow(() => secondPublicUnsubscribe());
assert.deepEqual(store.cleanupCalls, ['A', 'B']);
assert.doesNotThrow(() => invalidate());
});
test('unsubscribe and invalidate are reentry-safe and prevent callbacks already snapshotted by Store', () => {
const { host, invalidate, store } = makeLease();
const calls: string[] = [];
let unsubscribeSecond = (): void => undefined;
host.subscribe(() => {
calls.push('first');
if (calls.length > 2) {
unsubscribeSecond();
invalidate();
}
});
unsubscribeSecond = host.subscribe(() => { calls.push('second'); });
assert.deepEqual(calls, ['first', 'second']);
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
assert.deepEqual(calls, ['first', 'second', 'first']);
assert.doesNotThrow(() => unsubscribeSecond());
assert.doesNotThrow(() => unsubscribeSecond());
assert.doesNotThrow(() => invalidate());
store.playerReady(parsePlayerPreparePayload({ seat: 2 }));
assert.deepEqual(calls, ['first', 'second', 'first']);
});
test('invalidate is idempotent, old unsubscribe stays safe, and every stale Host method fails predictably', () => {
const { host, invalidate, store, sent } = makeLease();
let callbacks = 0;
const unsubscribe = host.subscribe(() => { callbacks++; });
assert.equal(callbacks, 1);
invalidate();
invalidate();
const disposed = (call: () => unknown) => assert.throws(call, GameSessionDisposedError);
disposed(() => host.getSnapshot());
disposed(() => host.subscribe(() => { callbacks++; }));
disposed(() => host.subscribe(null as never));
disposed(() => host.sendGameMessage('play-card', {}));
disposed(() => host.execute({ type: 'room.prepare' }));
disposed(() => host.seat.toView(0));
disposed(() => host.seat.toServer(0));
assert.doesNotThrow(() => unsubscribe());
assert.doesNotThrow(() => unsubscribe());
store.playerJoin(parseOtherJoinRoomPayload({
seat: 2, playerid: 77, nickname: 'other', avatar: 'http://b',
sex: 1, bean: 90, ip: '127.0.0.2', onstate: 0, isprepare: 0,
}));
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);
});