feat: integrate erqiwang rooms and use spec-compliant Web compilation

This commit is contained in:
2026-09-07 22:36:24 +08:00
parent 30a1b4114c
commit a7ec8aa6a7
49 changed files with 11603 additions and 1183 deletions
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { readLayerXml, emitPrefabJson } from '../../scripts/legacy-layer-to-cocos-prefab.mjs';
import { ROOT, HOST_PROJECT } from '../../scripts/lib/paths.mjs';
import { ROOT } from '../../scripts/lib/paths.mjs';
// PoC 阶段 3 端到端: Layer00002.xml Login_Layer (18 spirits, group 2, 包含 Spirit405 渠道logo +
// Spirit177 游客登录 anchor-test cases). 验证生成 prefab 与 fixtures/Login_Layer.prefab 在
@@ -19,7 +19,7 @@ import { ROOT, HOST_PROJECT } from '../../scripts/lib/paths.mjs';
// ImgResID 427 的依赖. 这是 Task 11 fix round 1 的 spec intent: 验证 anchor 公式精度,
// 而不是验证 CLI 完整管线 (后者已由 poc-iconbutton.test.mjs / poc-protocol.test.mjs 覆盖).
const FIXTURE_XML = join(ROOT, 'framework-tests', 'legacy-layer-migration', 'fixtures', 'Layer00002.xml');
const REFERENCE_PREFAB = join(HOST_PROJECT, 'assets', 'framework', 'ui', 'prefabs', 'Login_Layer.prefab');
const REFERENCE_PREFAB = join(ROOT, 'framework-tests', 'legacy-layer-migration', 'fixtures', 'Login_Layer.prefab');
function buildFilteredLayer() {
const layer = readLayerXml(FIXTURE_XML);
@@ -98,4 +98,4 @@ test('PoC 阶段 3 - 游客登录 _lpos 误差 < 0.001 (bypass CLI)', () => {
assert.ok(Math.abs(genVisitor._lpos.x - expVisitor._lpos.x) < 0.001, `x mismatch: gen=${genVisitor._lpos.x} exp=${expVisitor._lpos.x}`);
assert.ok(Math.abs(genVisitor._lpos.y - expVisitor._lpos.y) < 0.001, `y mismatch: gen=${genVisitor._lpos.y} exp=${expVisitor._lpos.y}`);
});
});
@@ -824,6 +824,7 @@ test('canonical roomtype preserves sparse length, holes, and negative zero', ()
assert.equal(state.room.kind, 'inside');
if (state.room.kind !== 'inside') return;
assert.ok(Array.isArray(state.room.roomtype));
assert.equal(state.room.roomtype.length, 5);
assert.equal(0 in state.room.roomtype, false);
assert.equal(1 in state.room.roomtype, true);
@@ -604,7 +604,7 @@ for (const mutate of [
setup.session.handleLogin(fixture('player-login-success.json'));
const before = setup.store.getState();
const data = {state: 0, roomcode: '123456', seat: 0, roomtype: [1, {count: 4}]};
setup.gameSession.entry.resolveSeatCount = roomtype => { mutate(roomtype); return 4; };
setup.gameSession.entry.resolveSeatCount = roomtype => { assert.ok(Array.isArray(roomtype)); mutate(roomtype); return 4; };
assert.throws(() => setup.session.handleCreateRoom(data), TypeError);
assert.deepEqual(data.roomtype, [1, {count: 4}]);
assert.equal(Object.isFrozen(data.roomtype), false);
@@ -1,3 +1,4 @@
import {createErqiwangGame} from '../../YouleNexus/assets/games/erqiwang/erqiwang-game.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});
@@ -736,7 +737,7 @@ test('a denied join retains the lobby root, reports the raw outcome and permits
});
test('runtime resolves seats only from the actual game-owned server roomtype at room entry', async () => {
const roomtypes: Array<readonly unknown[]> = [];
const roomtypes: Array<string | readonly unknown[]> = [];
const attached: GameHost[] = [];
const setup = makeRuntime(runtimeConfig(), makeGameEntry({
resolveSeatCount(roomtype) {
@@ -795,7 +796,7 @@ for(const loginRoute of ['agent','room','platform'])test(`${loginRoute} login de
const before = runtime.state;
runtime.login(ACCOUNT);
const denial = { state: -1, msg: '短信验证码不正确', time: 0 };
assert.equal(wire.sent.at(-1)?.route,'agent','outbound login remains agent');
assert.equal(wire.sent[wire.sent.length - 1]?.route,'agent','outbound login remains agent');
wire.emit({ type: 'message', message: { route: loginRoute, rpc: 'player_login', data: denial } });
assert.equal(runtime.state, before);
@@ -1651,7 +1652,7 @@ test('kick_offline bypasses pending login and stops late navigation', async () =
await runtime.start();wire.emit({type:'open',server:'ws://agent'});runtime.login(ACCOUNT);
const data={fromOther:true,gameid:'another-game'};
wire.emit({type:'message',message:{route:'agent',rpc:'kick_offline',data}});
assert.equal(scene.calls.at(-1)?.name,'kicked');assert.equal(scene.calls.at(-1)?.value,data);
assert.equal(scene.calls[scene.calls.length - 1]?.name,'kicked');assert.equal(scene.calls[scene.calls.length - 1]?.value,data);
assert.equal(wire.stopCalls,1);
const count=scene.calls.length;wire.emit({type:'open',server:'ws://agent'});
assert.equal(scene.calls.length,count);
@@ -1663,7 +1664,7 @@ test('show_message preserves text and legacy numeric timeout without navigating'
const before=runtime.state;
for(const time of ['3000','0','-1']){
wire.emit({type:'message',message:{route:'agent',rpc:'show_message',data:{msg:'服务器提示#第二行',time}}});
assert.deepEqual(scene.calls.at(-1),{name:'tips',value:{message:'服务器提示#第二行',time:Number(time)}});
assert.deepEqual(scene.calls[scene.calls.length - 1],{name:'tips',value:{message:'服务器提示#第二行',time:Number(time)}});
assert.equal(runtime.state,before);
}
runtime.stop();
@@ -2394,3 +2395,18 @@ test('create wire outcome enters only on success and a repeated success leaves t
if(runtime.state.room.kind==='inside')assert.equal(runtime.state.room.seatPlayerIds.length,10);
runtime.stop();assert.equal(disposals,1);
});
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();
});
@@ -0,0 +1,36 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {cloneAndFreezeRoomtype} from '../../YouleNexus/assets/framework/platform/stores/platform-store.ts';
import {parseCreateRoomResponse,parseRoomEntryFields} from '../../YouleNexus/assets/framework/protocol/contracts/room-contracts.ts';
import {ErqiwangRoomRules} from '../../YouleNexus/assets/games/erqiwang/erqiwang-room-rules.ts';
import {createRequire} from 'node:module';
const require=createRequire(import.meta.url);
const server=require('../../../server/games/erqiwang/class.config.js');
test('all 32 UI combinations match actual server config, round-trip and fees',()=>{
for(let mask=0;mask<32;mask++){
const r=new ErqiwangRoomRules();r.open(null);
for(let group=0;group<5;group++)r.select(group,(mask>>group)&1);
const code=r.build();assert.match(code,/^[01]{5}0{6}$/);
const cfg=server.parse(code);
assert.deepEqual([cfg.aset12,cfg.deductAA,cfg.nocheck,cfg.bangwang,cfg.climb],r.selection.map(Boolean));
const copy=new ErqiwangRoomRules();copy.open(code);assert.deepEqual(copy.selection,r.selection);
assert.equal(r.roomCards,(cfg.aset12?2:1)*(cfg.deductAA?1:2));
assert.equal(r.seatCount(code),3);
}
});
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));
assert.throws(()=>r.select(5,0));assert.throws(()=>r.select(0,2));
});
test('platform preserves erqiwang string through canonical state and entry responses',()=>{
const code='10100000000';
assert.equal(cloneAndFreezeRoomtype(code as never),code);
assert.equal(parseCreateRoomResponse({state:0,roomcode:'123456',seat:0,roomtype:code}).room?.roomtype,code);
assert.equal(parseRoomEntryFields({roomcode:'123456',seat:0,roomtype:code,players:[{},null,null]}).roomtype,code);
const array=[1,[2]];const copied=cloneAndFreezeRoomtype(array);
assert.deepEqual(copied,array);assert.notEqual(copied,array);
assert.ok(Object.isFrozen(copied));
});
@@ -11,5 +11,5 @@ test('template module attaches only when opened and releases its view and subscr
let actions:any;game.mount({render:(s,a)=>{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.at(-1),'closed');assert.throws(()=>actions.exit());
module.dispose();assert.equal(unsubscribed,1);assert.equal(views[views.length - 1],'closed');assert.throws(()=>actions.exit());
});