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:
@@ -0,0 +1,32 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {selectApplyVote} from '../../YouleNexus/assets/scripts/platform-login/apply-vote-model.ts';
|
||||
|
||||
function state(count=4):any {
|
||||
const participants=Array.from({length:count},(_,seat)=>({seat,playerid:seat+10,nickname:`玩家${seat}`,state:seat===0?0:seat===1?1:-1}));
|
||||
return {app:{phase:'logged-in'},dissolution:{kind:'active',roomcode:'123456',selfSeat:Math.min(2,count-1),initiator:participants[0],participants,deadline:120000}};
|
||||
}
|
||||
test('dynamic rows follow participant seats, excluding the initiator shown in the header',()=>{
|
||||
for(const count of [2,4,8,12]){
|
||||
const result=selectApplyVote(state(count),0)!;
|
||||
assert.equal(result.rows.length,count-1);
|
||||
assert.equal(result.rows[0].nickname,'【玩家1】');assert.equal(result.rows[0].status,'同意');
|
||||
assert.match(result.message,/发起投票:【玩家0】/);
|
||||
}
|
||||
});
|
||||
test('deadline only controls countdown and controls require an unvoted connected self',()=>{
|
||||
const input=state();assert.equal(selectApplyVote(input,118100)!.seconds,2);
|
||||
assert.equal(selectApplyVote(input,125000)!.seconds,0);
|
||||
assert.equal(selectApplyVote(input,125000)!.canVote,true);
|
||||
input.dissolution.selfSeat=0;assert.equal(selectApplyVote(input,0)!.canVote,false);
|
||||
input.dissolution.selfSeat=1;assert.equal(selectApplyVote(input,0)!.canVote,false);
|
||||
input.dissolution.selfSeat=2;input.app.phase='reconnecting';assert.equal(selectApplyVote(input,0)!.canVote,false);
|
||||
});
|
||||
test('server refusal produces the named result and hides voting/countdown',()=>{
|
||||
const input=state();input.dissolution={...input.dissolution,kind:'result',passed:false,refusedSeat:2,freeNow:false};
|
||||
const result=selectApplyVote(input,0)!;
|
||||
assert.match(result.message,/拒绝解散玩家:【玩家2】/);assert.equal(result.seconds,null);
|
||||
assert.equal(result.canVote,false);assert.equal(result.result,true);
|
||||
input.dissolution.freeNow=true;assert.equal(selectApplyVote(input,0),null);
|
||||
input.dissolution=null;assert.equal(selectApplyVote(input,0),null);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {DeferredNumericToolsPort} from '../../YouleNexus/assets/framework/presentation/deferred-numeric-tools.ts';
|
||||
import type {NumericToolsPort} from '../../YouleNexus/assets/framework/platform/game-host-adapter.ts';
|
||||
|
||||
function target(events:string[]):NumericToolsPort{return {
|
||||
openNumberInput:request=>{events.push('input:'+request.message);},closeNumberInput:()=>{events.push('closeInput');},
|
||||
showMultiplier:request=>{events.push('multiplier:'+request.values.join(','));},hideMultiplier:()=>{events.push('hideMultiplier');},
|
||||
close:()=>{events.push('close');},
|
||||
};}
|
||||
const input=(message:string)=>({message,onConfirm:()=>{}});
|
||||
const multiplier=(value:number)=>({values:[value],onConfirm:()=>{}});
|
||||
|
||||
test('early attach requests flush after binding in latest per-tool call order',()=>{
|
||||
const port=new DeferredNumericToolsPort(),events:string[]=[];
|
||||
port.openNumberInput(input('old'));port.showMultiplier(multiplier(2));port.openNumberInput(input('new'));
|
||||
assert.deepEqual(events,[]);port.bind(target(events));
|
||||
assert.deepEqual(events,['multiplier:2','input:new']);
|
||||
port.showMultiplier(multiplier(3));assert.equal(events[events.length-1],'multiplier:3');
|
||||
});
|
||||
test('close and hide cancel pending requests independently',()=>{
|
||||
const port=new DeferredNumericToolsPort(),events:string[]=[];
|
||||
port.openNumberInput(input('cancelled'));port.showMultiplier(multiplier(2));port.closeNumberInput();
|
||||
port.bind(target(events));assert.deepEqual(events,['multiplier:2']);
|
||||
port.unbind();port.showMultiplier(multiplier(3));port.hideMultiplier();port.bind(target(events));
|
||||
assert.deepEqual(events,['multiplier:2']);
|
||||
});
|
||||
test('lease close clears pending callbacks and unbinds the previous room',()=>{
|
||||
const port=new DeferredNumericToolsPort(),events:string[]=[];
|
||||
port.openNumberInput(input('old'));port.close();port.bind(target(events));assert.deepEqual(events,[]);
|
||||
port.close();assert.deepEqual(events,['close']);port.openNumberInput(input('new'));
|
||||
assert.deepEqual(events,['close']);port.bind(target(events));assert.deepEqual(events,['close','input:new']);
|
||||
});
|
||||
test('scene unbind retains pending requests while dropping only the current target',()=>{
|
||||
const port=new DeferredNumericToolsPort(),events:string[]=[];
|
||||
port.openNumberInput(input('pending'));port.unbind();port.bind(target(events));
|
||||
port.unbind();port.showMultiplier(multiplier(4));assert.deepEqual(events,['input:pending']);
|
||||
port.bind(target(events));assert.deepEqual(events,['input:pending','multiplier:4']);
|
||||
});
|
||||
test('bound dismissals forward directly and flushed requests are consumed exactly once',()=>{
|
||||
const port=new DeferredNumericToolsPort(),events:string[]=[];
|
||||
port.openNumberInput(input('once'));port.bind(target(events));port.closeNumberInput();port.hideMultiplier();
|
||||
port.unbind();port.bind(target(events));assert.deepEqual(events,['input:once','closeInput','hideMultiplier']);
|
||||
});
|
||||
test('lease close unbinds even when target cleanup throws',()=>{
|
||||
const port=new DeferredNumericToolsPort(),events:string[]=[];
|
||||
port.bind({...target(events),close(){throw Error('cleanup failed');}});
|
||||
assert.throws(()=>port.close(),/cleanup failed/);port.openNumberInput(input('next'));
|
||||
assert.deepEqual(events,[]);port.bind(target(events));assert.deepEqual(events,['input:next']);
|
||||
});
|
||||
|
||||
test('pending numeric requests receive cancellation on replacement and lease close',()=>{
|
||||
const port=new DeferredNumericToolsPort();const cancelled:string[]=[];
|
||||
port.openNumberInput({message:'first',onConfirm(){},onCancel(){cancelled.push('first');}});
|
||||
port.openNumberInput({message:'second',onConfirm(){},onCancel(){cancelled.push('second');}});
|
||||
port.showMultiplier({values:[1],onConfirm(){},onCancel(){cancelled.push('multiplier');throw Error('cancel failure');}});
|
||||
assert.deepEqual(cancelled,['first']);assert.throws(()=>port.close(),/cancel failure/);assert.deepEqual(cancelled,['first','second','multiplier']);
|
||||
assert.doesNotThrow(()=>port.close());
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {GAME_DEFINITIONS} from '../fixtures/game-definitions.ts';
|
||||
|
||||
test('unmigrated game settlement is explicit rather than silently acknowledging deskfree',()=>{
|
||||
for(const definition of Object.values(GAME_DEFINITIONS)){
|
||||
const game=definition.createGame('g'),module=game.entry.createModule();
|
||||
module.attach({capabilities:{room:{subscribe:()=>()=>{},getSnapshot:()=>({})}}} as any);
|
||||
try{assert.throws(()=>module.handlePlatformEvent({type:'room.dissolution-confirmed',freeNow:false,deskfree:{round:2}}),/解散结算.*尚未接入/);}
|
||||
finally{module.dispose();}
|
||||
}
|
||||
});
|
||||
test('game room exit always delegates to the bound platform policy',()=>{
|
||||
for(const definition of Object.values(GAME_DEFINITIONS)){
|
||||
const game=definition.createGame('g'),module=game.entry.createModule(),calls:unknown[]=[];
|
||||
let snapshot:any={room:{stage:1,infinite:0}},actions:any;
|
||||
module.attach({capabilities:{room:{subscribe:()=>()=>{},getSnapshot:()=>snapshot,requestExit:()=>calls.push('exit')}}} as any);
|
||||
game.mount({render:(_s,a)=>{actions=a;},close(){}});
|
||||
try{actions.exit();snapshot={room:{stage:0,infinite:0}};actions.exit();snapshot={room:{stage:1,infinite:1}};actions.exit();
|
||||
assert.deepEqual(calls,['exit','exit','exit']);}
|
||||
finally{module.dispose();}
|
||||
}
|
||||
});
|
||||
@@ -21,10 +21,26 @@ test('all 32 UI combinations match actual server config, round-trip and fees',()
|
||||
});
|
||||
test('required groups have defaults and malformed saved configuration is rejected',()=>{
|
||||
const r=new ErqiwangRoomRules();r.open(null);assert.equal(r.build(),'00000000000');
|
||||
for(const invalid of ['00000','00000000001','2'.repeat(11),[],{},123])assert.throws(()=>r.open(invalid));
|
||||
for(const invalid of ['', '0000','2'.repeat(11),[],{},123])assert.throws(()=>r.open(invalid));
|
||||
assert.throws(()=>r.select(5,0));assert.throws(()=>r.select(0,2));
|
||||
});
|
||||
|
||||
test('legacy and extended room configuration reads match server while new requests stay 11 bits',()=>{
|
||||
for(const code of ['11011','110110','11011000001','11011future-extension']){
|
||||
const r=new ErqiwangRoomRules();r.open(code);
|
||||
const cfg=server.parse(code);
|
||||
assert.deepEqual(r.selection.map(Boolean),[cfg.aset12,cfg.deductAA,cfg.nocheck,cfg.bangwang,cfg.climb]);
|
||||
assert.equal(r.seatCount(code),3);
|
||||
assert.equal(r.build(),'11011000000');
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid room configuration reports actual wire type and value',()=>{
|
||||
const r=new ErqiwangRoomRules();
|
||||
assert.throws(()=>r.seatCount([0,1,0]),/received array: \[0,1,0\]/);
|
||||
assert.throws(()=>r.seatCount('001'),/received string: "001"/);
|
||||
});
|
||||
|
||||
test('platform preserves erqiwang string through canonical state and entry responses',()=>{
|
||||
const code='10100000000';
|
||||
assert.equal(cloneAndFreezeRoomtype(code as never),code);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {KeyedRows} from '../../YouleNexus/assets/framework/presentation/keyed-rows.ts';
|
||||
import {ResourceLeaseCache} from '../../YouleNexus/assets/framework/presentation/resource-lease-cache.ts';
|
||||
|
||||
test('append preserves existing rows; updates, removals and teardown affect only owned rows',()=>{
|
||||
const created:number[]=[],destroyed:number[]=[];
|
||||
const rows=new KeyedRows<{id:number;text:string},{id:number}>({key:item=>item.id,create:item=>{created.push(item.id);return {id:item.id};},update:()=>{},remove:row=>destroyed.push(row.id)});
|
||||
const one={id:1,text:'a'};rows.render([one]);const original=rows.get(1);
|
||||
rows.render([one,{id:2,text:'b'}]);assert.equal(rows.get(1),original);assert.deepEqual(created,[1,2]);assert.deepEqual(destroyed,[]);
|
||||
rows.render([{id:2,text:'changed'}]);assert.deepEqual(destroyed,[1]);rows.dispose();assert.deepEqual(destroyed,[1,2]);
|
||||
});
|
||||
test('avatar loads are deduplicated, late completion after release is bounded and cleanup is exact',async()=>{
|
||||
let loadCount=0;const disposed:string[]=[];let resolve!:(value:string)=>void;
|
||||
const cache=new ResourceLeaseCache<string>({idleCapacity:0,load:()=>{loadCount++;return new Promise(r=>resolve=r);},destroy:value=>disposed.push(value)});
|
||||
const a=cache.acquire('url'),b=cache.acquire('url');assert.equal(loadCount,1);a.release();resolve('texture');
|
||||
assert.equal(await b.value,'texture');assert.equal(cache.leases,1);b.release();b.release();assert.equal(cache.leases,0);assert.deepEqual(disposed,['texture']);
|
||||
});
|
||||
test('an idle cache retains only the explicitly configured budget and retries failed loads',async()=>{
|
||||
let calls=0;const destroyed:string[]=[];
|
||||
const cache=new ResourceLeaseCache<string>({idleCapacity:1,load:async url=>{calls++;if(url==='bad')throw Error('load');return url;},destroy:x=>destroyed.push(x)});
|
||||
for(const url of ['a','b']){const lease=cache.acquire(url);await lease.value;lease.release();}
|
||||
assert.deepEqual(destroyed,['a']);assert.equal(cache.size,1);
|
||||
const bad=cache.acquire('bad');await assert.rejects(bad.value);bad.release();const retry=cache.acquire('bad');await assert.rejects(retry.value);retry.release();assert.equal(calls,4);
|
||||
cache.clear();assert.deepEqual(destroyed,['a','b']);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {NumericInputModel} from '../../YouleNexus/assets/framework/presentation/numeric-input-model.ts';
|
||||
import {MultiplierModel} from '../../YouleNexus/assets/framework/presentation/multiplier-model.ts';
|
||||
|
||||
test('numeric input preserves string leading zeros and caller owns closing',()=>{
|
||||
const results:(string|number)[]=[];
|
||||
const model=new NumericInputModel({message:'请输入',begin0:true,onConfirm:value=>results.push(value)});
|
||||
assert.equal(model.text,'');model.inputDigit(0);model.inputDigit(7);
|
||||
assert.equal(model.confirm(),'07');assert.deepEqual(results,['07']);assert.equal(model.text,'07');
|
||||
model.backspace();model.backspace();model.backspace();assert.equal(model.text,'');
|
||||
});
|
||||
test('numeric input number mode replaces zero and confirms number',()=>{
|
||||
const model=new NumericInputModel({message:'金额',begin0:false,onConfirm:()=>{}});
|
||||
assert.equal(model.text,'0');model.inputDigit(0);model.inputDigit(8);assert.equal(model.confirm(),8);
|
||||
model.backspace();assert.equal(model.text,'0');
|
||||
});
|
||||
test('numeric input filters paste and enforces 18 digits without losing prior value',()=>{
|
||||
const model=new NumericInputModel({message:'ID',begin0:true,onConfirm:()=>{}});
|
||||
assert.equal(model.paste('id: 0001-23456789012345'),null);assert.equal(model.text,'000123456789012345');
|
||||
assert.equal(model.inputDigit(9),'输入超出范围');assert.equal(model.paste('1'.repeat(19)),'输入超出范围');
|
||||
assert.equal(model.text,'000123456789012345');model.paste('abc');assert.equal(model.text,'');
|
||||
assert.throws(()=>model.inputDigit(12));
|
||||
});
|
||||
function storage(initial:string|null=null){
|
||||
const values=new Map<string,string>();if(initial!==null)values.set('tsgame_multiple_161007',initial);
|
||||
return {values,getItem:(key:string)=>values.get(key)??null,setItem:(key:string,value:string)=>{values.set(key,value);}};
|
||||
}
|
||||
test('calculator defaults its persisted preference at the source and resets invalid preference',()=>{
|
||||
for(const initial of [null,'0','nonsense','1b2','-1']){
|
||||
const saved=storage(initial);const model=new MultiplierModel({originalValues:[2],onConfirm:()=>{},storage:saved,gameid:16,agentid:1007});
|
||||
assert.equal(model.text,'1');assert.equal(saved.values.get('tsgame_multiple_161007'),'1');
|
||||
}
|
||||
});
|
||||
test('calculator uses b marker, rejects duplicate decimal and 9th character',()=>{
|
||||
const model=new MultiplierModel({originalValues:[],onConfirm:()=>{},storage:storage('1.2'),gameid:16,agentid:1007});
|
||||
assert.equal(model.text,'1b2');assert.equal(model.inputDecimal(),'输入不正确!');
|
||||
for(const digit of [3,4,5,6,7])assert.equal(model.inputDigit(digit),null);
|
||||
assert.equal(model.inputDigit(8),'输入长度超过限制!');assert.equal(model.text,'1b234567');
|
||||
assert.throws(()=>model.inputDigit(-1));
|
||||
});
|
||||
test('calculator multiplies supplied originals with decimal precision and persists before callback',()=>{
|
||||
const saved=storage('0.2');const originals=[0.1,-1.25,0];let result:number[]=[];
|
||||
const model=new MultiplierModel({originalValues:originals,onConfirm:values=>{result=values;assert.equal(saved.getItem('tsgame_multiple_161007'),'0.2');},storage:saved,gameid:16,agentid:1007});
|
||||
originals[0]=999;assert.deepEqual(model.confirm(),[0.02,-0.25,0]);assert.deepEqual(result,[0.02,-0.25,0]);
|
||||
});
|
||||
test('calculator zero confirms as one and normalizes display',()=>{
|
||||
const model=new MultiplierModel({originalValues:[3],onConfirm:()=>{},storage:storage(),gameid:16,agentid:1007});
|
||||
model.backspace();assert.equal(model.text,'0');model.inputDecimal();assert.equal(model.text,'0b');
|
||||
assert.deepEqual(model.confirm(),[3]);assert.equal(model.text,'1');assert.equal(model.multiplier,1);
|
||||
});
|
||||
test('calculator refuses non-finite original values instead of fabricating results',()=>{
|
||||
assert.throws(()=>new MultiplierModel({originalValues:[NaN],onConfirm:()=>{},storage:storage(),gameid:16,agentid:1007}));
|
||||
});
|
||||
|
||||
test('calculator decimal multiplication supports finite scientific notation',()=>{
|
||||
const model=new MultiplierModel({originalValues:[1e30,1e-7],onConfirm:()=>{},storage:storage('0.2'),gameid:16,agentid:1007});
|
||||
assert.deepEqual(model.confirm(),[2e29,2e-8]);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {OwnedFeedback} from '../../YouleNexus/assets/framework/presentation/owned-feedback.ts';
|
||||
test('loading owners include legacy platform activity and only last release hides the surface',()=>{
|
||||
const shown:boolean[]=[];const owned=new OwnedFeedback({loading:v=>shown.push(v),tip(){},closeTip(){}});
|
||||
const a=owned.beginLoading(),b=owned.beginLoading();owned.setPlatformLoading(true);a.close();b.close();assert.equal(shown[shown.length-1],true);
|
||||
owned.setPlatformLoading(false);assert.equal(shown[shown.length-1],false);a.close();assert.equal(shown.length,6);
|
||||
});
|
||||
test('closing an old tip owner never closes a newer business or platform tip',()=>{
|
||||
const log:string[]=[];const owned=new OwnedFeedback({loading(){},tip:message=>log.push(message),closeTip:()=>log.push('closed')});
|
||||
const first=owned.showBusinessTip('first',1),second=owned.showBusinessTip('second',1);first.close();assert.deepEqual(log,['first','second']);
|
||||
second.close();second.close();assert.deepEqual(log,['first','second','closed']);owned.dispose();assert.throws(()=>owned.beginLoading(),/disposed/);
|
||||
});
|
||||
test('feedback cleanup attempts both surfaces and old owner handles become inert',()=>{
|
||||
const log:string[]=[];const owned=new OwnedFeedback({loading:v=>{log.push('loading:'+v);if(!v)throw Error('loading cleanup');},tip(){},closeTip:()=>{log.push('tip');throw Error('tip cleanup');}});
|
||||
const handle=owned.beginLoading();assert.throws(()=>owned.dispose(),error=>{assert.equal((error as Error&{errors:unknown[]}).errors.length,2);return true;});
|
||||
assert.deepEqual(log,['loading:true','loading:false','tip']);assert.doesNotThrow(()=>handle.close());assert.doesNotThrow(()=>owned.dispose());
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {PlatformSettings} from '../../YouleNexus/assets/framework/presentation/platform-settings.ts';
|
||||
|
||||
function storage(){const values=new Map<string,string>();return {values,getItem:(k:string)=>values.get(k)??null,setItem:(k:string,v:string)=>{values.set(k,v);}};}
|
||||
test('browser switches default on, persist independently, and restore after reopening',()=>{
|
||||
const store=storage();const settings=new PlatformSettings(store,568,3,{kind:'browser'});
|
||||
assert.equal(settings.read('shake'),true);assert.equal(settings.read('voice'),true);
|
||||
assert.equal(settings.toggle('shake'),false);assert.equal(settings.toggle('voice'),false);
|
||||
const reopened=new PlatformSettings(store,568,3,{kind:'browser'});
|
||||
assert.equal(reopened.read('shake'),false);assert.equal(reopened.read('voice'),false);
|
||||
assert.equal(reopened.toggle('shake'),true);
|
||||
assert.equal(store.getItem('tsgame_shake_5683'),'1');assert.equal(store.getItem('tsgame_voice_5683'),'0');
|
||||
assert.equal(new PlatformSettings(store,568,4,{kind:'browser'}).read('voice'),true);
|
||||
});
|
||||
test('lobby toggles preserve native voice and shake sound commands without starting a game shake action',()=>{
|
||||
const store=storage();const calls:unknown[]=[];
|
||||
const settings=new PlatformSettings(store,568,3,{kind:'bridge',call:(name,data)=>calls.push([name,data])});
|
||||
settings.toggle('voice');settings.toggle('voice');settings.toggle('shake');
|
||||
store.setItem('tsgame_sound_5683','0');settings.toggle('shake');settings.toggle('sound');
|
||||
assert.deepEqual(calls,[['voicePlaying',0],['voicePlaying',1],['stopshake',''],['SwitchShake',0],['SwitchShake',1]]);
|
||||
});
|
||||
test('invalid saved preferences are exposed rather than silently replaced',()=>{
|
||||
const store=storage();store.setItem('tsgame_voice_5683','bad');
|
||||
const settings=new PlatformSettings(store,568,3,{kind:'browser'});
|
||||
assert.throws(()=>settings.toggle('voice'),/tsgame_voice_5683/);
|
||||
assert.equal(store.getItem('tsgame_voice_5683'),'bad');
|
||||
});
|
||||
test('entering the lobby restores a cached muted voice state to native',()=>{
|
||||
const store=storage();store.setItem('tsgame_voice_5683','0');const calls:unknown[]=[];
|
||||
const settings=new PlatformSettings(store,568,3,{kind:'bridge',call:(name,data)=>calls.push([name,data])});
|
||||
settings.synchronizeVoice();
|
||||
assert.deepEqual(calls,[['voicePlaying',0]]);
|
||||
assert.equal(settings.read('voice'),false);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
import {buildRoomMenuModel, roomMenuAction, roomEscapeConfirmation, type RoomMenuContext} from '../../YouleNexus/assets/framework/presentation/room-menu-model.ts';
|
||||
import {resolveRoomChatPhrase, validateRoomChatText, roomChatByteLength, resolveRoomChatPolicy} from '../../YouleNexus/assets/framework/presentation/room-chat-config.ts';
|
||||
import {getGameDefinition} from '../fixtures/game-definitions.ts';
|
||||
const getRoomChatConfig=(key:string)=>getGameDefinition(key).chat;
|
||||
import {resolveRoomMenuContext} from '../../YouleNexus/assets/framework/presentation/room-menu-context.ts';
|
||||
|
||||
const context: RoomMenuContext = {status:1,canexit:1,stage:0,playercnt:2,roomMode:1,infinite:0,shortcode:0,matchId:0,h5Version:1,appdownload:'https://example.test/app',hideEscape:false,mainSceneButton:false,vipInfinite:false,versionState:0,roomCardName:'房卡',bean:200,starName:'金币'};
|
||||
const ids=(patch:Partial<RoomMenuContext>)=>buildRoomMenuModel({...context,...patch}).map(button=>button.spiritId);
|
||||
test('legacy panel keeps ordered, compact visible buttons and final infinite override',()=>{
|
||||
assert.deepEqual(ids({}),[151,150,185,233,513,656]);
|
||||
assert.deepEqual(buildRoomMenuModel(context).map(button=>button.y),[75,168,261,354,447,540]);
|
||||
assert.deepEqual(ids({stage:1,canexit:0}),[151,150,185,656]);
|
||||
assert.deepEqual(ids({stage:1,canexit:1}),[151,150,233,656]);
|
||||
assert.deepEqual(ids({stage:1,canexit:0,infinite:1}),[151,150,233,3014,656]);
|
||||
assert.deepEqual(ids({infinite:1,shortcode:123}),[151,150,233,656]);
|
||||
assert.deepEqual(ids({infinite:1,vipInfinite:true}),[151,150,233,656]);
|
||||
});
|
||||
test('legacy escape guards and match hide ordering are preserved',()=>{
|
||||
for(const patch of [{status:2},{playercnt:3},{roomMode:0},{hideEscape:true},{mainSceneButton:true}])assert.ok(!ids(patch).includes(513));
|
||||
assert.ok(ids({matchId:3}).includes(185));
|
||||
assert.ok(!ids({h5Version:0}).includes(656));
|
||||
assert.ok(!ids({appdownload:''}).includes(656));
|
||||
});
|
||||
test('menu actions distinguish owner exit, pregame break and in-game dissolution',()=>{
|
||||
assert.deepEqual(roomMenuAction(233,context),{kind:'confirm',checkType:2,message:'房间仍存在,是否退出?',command:'room.exit'});
|
||||
assert.deepEqual(roomMenuAction(233,{...context,status:2}),{kind:'command',command:'room.exit'});
|
||||
assert.deepEqual(roomMenuAction(185,context),{kind:'confirm',checkType:1,message:'解散房间不扣房卡,是否解散?',command:'room.break'});
|
||||
assert.equal(roomMenuAction(185,{...context,versionState:1}).message,' 是否解散房间?');
|
||||
assert.deepEqual(roomMenuAction(185,{...context,stage:1,canexit:0}),{kind:'command',command:'room.apply-dissolution'});
|
||||
assert.throws(()=>roomMenuAction(3014,context),/not visible/);
|
||||
});
|
||||
test('escape asks for quantity then keeps legacy confirmation and balance error',()=>{
|
||||
assert.deepEqual(roomMenuAction(513,context),{kind:'input',message:'请输入投降扣除数量(底翻)'});
|
||||
assert.deepEqual(roomEscapeConfirmation(context,30),{kind:'confirm',checkType:3,message:'将扣除30金币,确定逃跑?',command:'room.escape',count:30});
|
||||
assert.deepEqual(roomEscapeConfirmation(context,300),{kind:'tip',message:'输入大于您拥有的300金币数:200!'});
|
||||
assert.throws(()=>roomEscapeConfirmation(context,NaN));
|
||||
});
|
||||
test('chat config uses exact game source, paired audio and one-based incoming indexes',()=>{
|
||||
const template=getRoomChatConfig('Game_Surface_3'),eqw=getRoomChatConfig('erqiwang');
|
||||
assert.equal(resolveRoomChatPhrase(template,1).text,'你好');
|
||||
assert.equal(resolveRoomChatPhrase(eqw,1).text,'快点吧,我等的花儿都谢了。');
|
||||
assert.equal(resolveRoomChatPhrase(eqw,7).text,'我先走了,后会有期');
|
||||
assert.ok(eqw.phrases.every(phrase=>phrase.audio===''));
|
||||
assert.equal(eqw.limitLength,40);assert.equal(eqw.showChatMs,2000);
|
||||
assert.throws(()=>getRoomChatConfig('unknown'),/Unknown/);
|
||||
assert.equal(resolveRoomChatPhrase(eqw,8).text,eqw.phrases[0].text);
|
||||
for(const index of [0,1.5,NaN])assert.throws(()=>resolveRoomChatPhrase(eqw,index),/index/);
|
||||
});
|
||||
test('chat validation preserves source byte count and substring filtering',()=>{
|
||||
const config=getRoomChatConfig('Game_Surface_3');
|
||||
assert.equal(roomChatByteLength('a^你😀'),9);
|
||||
assert.doesNotThrow(()=>validateRoomChatText(config,'你好'));
|
||||
assert.throws(()=>validateRoomChatText(config,' '),/请输入/);
|
||||
assert.doesNotThrow(()=>validateRoomChatText(config,'你'.repeat(40)));
|
||||
assert.throws(()=>validateRoomChatText(config,'你'.repeat(41)),/40/);
|
||||
assert.doesNotThrow(()=>validateRoomChatText(config,'你'.repeat(21),true));
|
||||
assert.throws(()=>validateRoomChatText(config,'毛泽东'),/内容含有敏感词汇请重新输入!/);
|
||||
assert.equal(config.announcementLimit,10000);assert.equal(config.voiceHistoryTag,20000);
|
||||
assert.deepEqual(resolveRoomChatPolicy({raw:{},getValue:()=>undefined}),{noticeLimit:-1,noticeCost:0});
|
||||
assert.deepEqual(resolveRoomChatPolicy({raw:{},getValue:key=>key==='noticelimit'?100:2}),{noticeLimit:100,noticeCost:2});
|
||||
});
|
||||
test('room menu source resolver reports missing authoritative room state',()=>{
|
||||
assert.throws(()=>resolveRoomMenuContext({room:{kind:'outside'}} as any,{} as any,getGameDefinition('Game_Surface_3').roomMenu,{h5Version:1,versionState:0}),/inside room/);
|
||||
const state:any={room:{kind:'inside',roomcode:'123456',stage:0,infinite:0,roomMode:1,status:1,canexit:1,shortcode:null,matchId:null,seatPlayerIds:[1,null]},players:{selfPlayerId:1,entities:{1:{bean:100}}}};
|
||||
const config:any={identity:{},remoteConfig:{raw:{},getValue:()=>undefined}};
|
||||
const resolved=resolveRoomMenuContext(state,config,getGameDefinition('Game_Surface_3').roomMenu,{h5Version:1,versionState:0});
|
||||
assert.equal(resolved.playercnt,1);assert.equal(resolved.shortcode,null);assert.equal(resolved.bean,100);
|
||||
assert.equal(resolved.roomCardName,'房卡');assert.equal(resolved.appdownload,'');
|
||||
delete state.room.status;
|
||||
assert.throws(()=>resolveRoomMenuContext(state,config,getGameDefinition('Game_Surface_3').roomMenu,{h5Version:1,versionState:0}),/status/);
|
||||
});
|
||||
test('both configured banned corpora match their own authoritative legacy source',()=>{
|
||||
for(const gameKey of ['Game_Surface_3','erqiwang']){
|
||||
const source=readFileSync(new URL(`../../../projects/${gameKey}/js/00_Surface/03_Banwords.js`,import.meta.url),'utf8');
|
||||
const literal=/var banwords\s*=\s*("[\s\S]*?");/.exec(source);
|
||||
assert.ok(literal);
|
||||
const words=String(runInNewContext(literal[1])).split('、');
|
||||
assert.deepEqual(getRoomChatConfig(gameKey).bannedWords,words);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {shouldShowOwnerNotice} from '../../YouleNexus/assets/framework/presentation/room-owner-notice.ts';
|
||||
|
||||
test('owner notice writes game-agent scoped expiry once per 24 hours including exact boundary',()=>{
|
||||
const values=new Map<string,string>();let writes=0;
|
||||
const storage={getItem:(key:string)=>values.get(key)??null,setItem:(key:string,value:string)=>{writes++;values.set(key,value);}};
|
||||
const now=1000,day=24*60*60*1000;
|
||||
assert.equal(shouldShowOwnerNotice(storage,'12','34',now),true);
|
||||
assert.equal(values.get('tsgame_ownerNoteTime_1234'),String(now+day));
|
||||
assert.equal(shouldShowOwnerNotice(storage,'12','34',now+day-1),false);
|
||||
assert.equal(writes,1);
|
||||
assert.equal(shouldShowOwnerNotice(storage,'12','34',now+day),true);
|
||||
assert.equal(values.get('tsgame_ownerNoteTime_1234'),String(now+2*day));
|
||||
assert.equal(shouldShowOwnerNotice(storage,'12','35',now),true);
|
||||
});
|
||||
test('owner notice treats missing, empty and zero expiry as due but exposes corrupt storage',()=>{
|
||||
for(const value of [null,'','0']){
|
||||
let saved='';assert.equal(shouldShowOwnerNotice({getItem:()=>value,setItem:(_key,value)=>{saved=value;}},'1','2',10),true);
|
||||
assert.equal(saved,'86400010');
|
||||
}
|
||||
for(const value of ['abc','NaN','Infinity','-1','1x',' ','1.5']){
|
||||
assert.throws(()=>shouldShowOwnerNotice({getItem:()=>value,setItem:()=>assert.fail('must not overwrite corrupt source')},'1','2',10),/expiry/);
|
||||
}
|
||||
assert.throws(()=>shouldShowOwnerNotice({getItem:()=>null,setItem:()=>{}},'1','2',NaN),/now/);
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createTemplateGame} from '../../YouleNexus/assets/games/template/template-game.ts';
|
||||
import {GAME_DEFINITIONS} from '../fixtures/game-definitions.ts';
|
||||
test('template module attaches only when opened and releases its view and subscription',()=>{
|
||||
let unsubscribed=0;const commands:unknown[]=[];const views:unknown[]=[];
|
||||
const snapshot={connection:{phase:'logged-in'},self:{playerId:1,seat:0},room:{roomcode:'123456',roomtype:[],stage:0,needprepare:0,infinite:0},seats:[]};
|
||||
const game=createTemplateGame('g');const probe=game.entry.createModule();probe.dispose();
|
||||
const game=GAME_DEFINITIONS.Game_Surface_3.createGame('g');const probe=game.entry.createModule();probe.dispose();
|
||||
assert.throws(()=>game.mount({render:s=>views.push(s),close(){}}));
|
||||
const module=game.entry.createModule();
|
||||
module.attach({getSnapshot:()=>snapshot,subscribe:()=>()=>unsubscribed++,execute:(c:unknown)=>commands.push(c),seat:{toView:(n:number)=>n,toServer:(n:number)=>n},sendGameMessage(){}} as any);
|
||||
let actions:any;game.mount({render:(s,a)=>{views.push(s);actions=a;},close:()=>views.push('closed')});
|
||||
const module=game.entry.createModule();assert.equal(game.roomProjection,null);assert.equal(module.roomProjection,null);
|
||||
module.attach({capabilities:{room:{getSnapshot:()=>snapshot,subscribe:()=>()=>unsubscribed++,prepare:()=>commands.push({type:'room.prepare'}),requestExit:()=>commands.push({type:'room.exit'})}}} as any);
|
||||
let actions:any;game.mount({render:(s,a,projection)=>{assert.equal(projection,null);views.push(s);actions=a;},close:()=>views.push('closed')});
|
||||
actions.exit();actions.prepare();assert.deepEqual(commands,[{type:'room.exit'},{type:'room.prepare'}]);
|
||||
const deskinfo={nested:[1,2]};module.restore(deskinfo);assert.equal(game.restoredSnapshot,deskinfo);
|
||||
module.dispose();assert.equal(unsubscribed,1);assert.equal(views[views.length - 1],'closed');assert.throws(()=>actions.exit());
|
||||
|
||||
Reference in New Issue
Block a user