diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/room-store.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/room-store.ts index 07ad562..081148c 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/room-store.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/room-store.ts @@ -14,6 +14,7 @@ const INITIAL: RoomState = { makewar: 0, needprepare: 0, infinite: 0, + state: 0, players: [], deskinfo: null, }; diff --git a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/types.ts b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/types.ts index db3da23..991105e 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/types.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/platform/stores/types.ts @@ -37,6 +37,11 @@ export interface RoomState { seat: number; // 自己的座位(-1 未入座) isowner: number; // 1房主/0非 asetcount: number; makewar: number; needprepare: number; infinite: number; + /** + * 房间解散状态:0=正常 1=投票中 2=已解散。 + * 业务侧触发清理/离开。 + */ + state: 0 | 1 | 2; /** * 按座位下标的玩家数组。空座位用 null 占位(不是 undefined、不是空对象), * 长度由 maxPlayerCount 决定,由 RoomStore.applyRecovery 校验填充。 diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/room-handlers.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/room-handlers.ts new file mode 100644 index 0000000..62f6793 --- /dev/null +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/room-handlers.ts @@ -0,0 +1,124 @@ +import type { RoomStore } from '../platform/stores/room-store.ts'; +import type { PlayerStore } from '../platform/stores/player-store.ts'; + +/** + * Room handler 上下文:注入 RoomStore + 自己的 PlayerStore(部分 handler 需要)。 + * + * 业务侧(PlatformSession)在收到 room route rpc 时,从 NetClient 事件总线 + * 取出 Envelope{route:'room', rpc, data} → Router 派发 → 查 roomHandlers[rpc](ctx, data)。 + */ +export interface RoomHandlerContext { + room: RoomStore; + player: PlayerStore; +} + +/** 单个 handler 的数据形参 —— 各 rpc 不同,这里只规定最基础形状。 */ +export type RoomRpcData = Record; + +/** 单个 handler 的签名 */ +export type RoomHandler = (ctx: RoomHandlerContext, data: RoomRpcData) => void; + +/** + * 把任意 rpc 名映射到对应 handler;未注册的显式抛错(第二准则:不静默吞包)。 + * + * 为避免构造后被外部写入不安全 handler,采用 Object.freeze。 + */ +function defineHandlers(table: Record): Readonly> { + const wrapped: Record = {}; + for (const [k, v] of Object.entries(table)) wrapped[k] = v; + return Object.freeze(wrapped); +} + +function seatIndex(seat: unknown, max: number): number { + if (typeof seat !== 'number' || !Number.isInteger(seat) || seat < 1 || seat > max) { + throw new Error( + `Room handler: 无效座位 seat=${String(seat)}(1 基,有效范围 1..${max})`, + ); + } + return seat - 1; // 1 基 → 0 基 +} + +export const roomHandlers = defineHandlers({ + /** 自己加入房间(通常在 login/self_join_room 走完) */ + self_join_room(ctx, data) { + const seat = seatIndex(data.seat, ctx.room.state.value.players.length || 8); + // 自己加入:把 seat 写入(其它 fields 由 login/applyRecovery 统一管理) + (ctx.room.state.value as any).seat = seat + 1; + }, + + /** 第三方加入:把对应 seat 位置填上 PlayerState */ + other_join_room(ctx, data) { + const len = ctx.room.state.value.players.length; + const i = seatIndex(data.seat, len); + const next = [...ctx.room.state.value.players]; + next[i] = { + playerid: data.playerid as number, + nickname: data.nickname as string, + avatar: (data.avatar as string) ?? '', + sex: (data.sex as number) ?? 0, + bean: (data.bean as number) ?? 0, + roomcard: (data.roomcard as number) ?? 0, + score: 0, charm: 0, taskstate: 0, advanced: 0, + bankpower: 0, bank: 0, bankpwd: 0, + ip: '', sign: null, tel: null, + invitecode: null, initCard: 0, initBean: 0, + }; + (ctx.room.state.value as any).players = next; + }, + + /** 自己退出:清空自己座位;PlayerStore 不动(退出房间不抹掉登录态) */ + self_exit_room(ctx, data) { + const len = ctx.room.state.value.players.length; + const i = seatIndex(data.seat, len); + const next = [...ctx.room.state.value.players]; + next[i] = null; + (ctx.room.state.value as any).players = next; + }, + + /** 第三方退出:清空对应座位 */ + other_exit_room(ctx, data) { + const len = ctx.room.state.value.players.length; + const i = seatIndex(data.seat, len); + const next = [...ctx.room.state.value.players]; + next[i] = null; + (ctx.room.state.value as any).players = next; + }, + + /** 自己发起解散投票:state=1 */ + self_apply_free_room(ctx, _data) { + (ctx.room.state.value as any).state = 1; + }, + + /** 第三方发起解散投票:state=1 */ + other_apply_free_room(ctx, _data) { + (ctx.room.state.value as any).state = 1; + }, + + /** 同意解散:不改变 state(agreeList 由其他协议累计) */ + self_agree_free_room(_ctx, _data) { /* no-op */ }, + other_agree_free_room(_ctx, _data) { /* no-op */ }, + + /** 拒绝解散:不改变 state */ + self_refuse_free_room(_ctx, _data) { /* no-op */ }, + other_refuse_free_room(_ctx, _data) { /* no-op */ }, + + /** 离线/上线:RoomStore 不持有 offline 字段,handler 是 no-op(业务侧订阅子游戏 IGameModule 钩子) */ + other_offline(_ctx, _data) { /* no-op */ }, + other_online(_ctx, _data) { /* no-op */ }, + + /** 换座位:从一个座位换到另一个 */ + change_seat(ctx, data) { + const len = ctx.room.state.value.players.length; + const from = seatIndex(data.from, len); + const to = seatIndex(data.to, len); + if (from === to) return; + const next = [...ctx.room.state.value.players]; + [next[from], next[to]] = [next[to], next[from]]; + (ctx.room.state.value as any).players = next; + }, + + /** 解散投票结束:state=2(业务侧触发清场逻辑) */ + free_room(ctx, _data) { + (ctx.room.state.value as any).state = 2; + }, +}); \ No newline at end of file diff --git a/cocoscreator_projects/framework-tests/protocol/room-handlers.test.ts b/cocoscreator_projects/framework-tests/protocol/room-handlers.test.ts new file mode 100644 index 0000000..5bbd9af --- /dev/null +++ b/cocoscreator_projects/framework-tests/protocol/room-handlers.test.ts @@ -0,0 +1,137 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { RoomStore } from '../../YouleNexus/assets/framework/platform/stores/room-store.ts'; +import { PlayerStore } from '../../YouleNexus/assets/framework/platform/stores/player-store.ts'; +import { roomHandlers } from '../../YouleNexus/assets/framework/protocol/room-handlers.ts'; +import type { RoomRecoveryData } from '../../YouleNexus/assets/framework/platform/stores/room-store.ts'; + +function makeRoom() { + const room = new RoomStore(); + room.applyRecovery({ + roomcode: 'R1', isbattle: 1, roommode: 1, + seat: 1, isowner: 0, + players: [ + null, + { playerid: 100, nickname: '甲', bean: 10, roomcard: 0 } as any, + { playerid: 200, nickname: '乙', bean: 20, roomcard: 0 } as any, + null, + ], + } as RoomRecoveryData); + return room; +} + +function makePlayer() { + const p = new PlayerStore(); + p.applyLogin({ playerid: 100, nickname: '甲', bean: 10, roomcard: 0 } as any); + return p; +} + +test('self_join_room: 已在房应被覆盖(再次入房罕见但支持)', () => { + const room = makeRoom(); + // 假设自己是 seat=2 加入 + roomHandlers.self_join_room({ room }, { seat: 2, playerid: 200 } as any); + assert.equal(room.state.value.seat, 2); +}); + +test('other_join_room: 第三方加入,players 数组对应座位填入', () => { + const room = new RoomStore(); + room.applyRecovery({ + roomcode: 'R1', seat: 1, isowner: 0, + players: [null, { playerid: 100 } as any, null, null], + } as RoomRecoveryData); + roomHandlers.other_join_room({ room }, { seat: 3, playerid: 300, nickname: '丙' } as any); + const v = room.state.value; + assert.equal(v.players[2]?.playerid, 300, 'seat=3 应填到 players[2]'); + assert.equal(v.players[2]?.nickname, '丙'); +}); + +test('self_exit_room: 自己座位清空', () => { + const room = makeRoom(); + // 假设自己 seat=1 + roomHandlers.self_exit_room({ room, player: makePlayer() }, { seat: 1 } as any); + assert.equal(room.state.value.players[0], null, 'seat=1(0基)应清空'); + // 自己的 PlayerStore 不变(退出房间不抹掉登录态) +}); + +test('other_exit_room: 第三方座位清空', () => { + const room = makeRoom(); + roomHandlers.other_exit_room({ room }, { seat: 2 } as any); + assert.equal(room.state.value.players[1], null); +}); + +test('other_exit_room: 未知座位 → 显式抛错(第二准则)', () => { + const room = makeRoom(); + assert.throws( + () => roomHandlers.other_exit_room({ room }, { seat: 99 } as any), + /seat=99/, + ); +}); + +test('self_apply_free_room: state 进入解散投票中', () => { + const room = makeRoom(); + roomHandlers.self_apply_free_room({ room }, { applyer: 1 } as any); + assert.equal(room.state.value.state, 1); +}); + +test('other_apply_free_room: state 进入解散投票中', () => { + const room = makeRoom(); + roomHandlers.other_apply_free_room({ room }, { applyer: 2 } as any); + assert.equal(room.state.value.state, 1); +}); + +test('self/other_agree_free_room: 不改变 state,仅标记同意', () => { + const room = makeRoom(); + roomHandlers.self_apply_free_room({ room }, {} as any); + roomHandlers.self_agree_free_room({ room, player: makePlayer() }, {} as any); + // 协议无 agreeList 字段(state 保持 1=投票中),由其他机制累计 + assert.equal(room.state.value.state, 1); +}); + +test('self/other_refuse_free_room: 不改变 state', () => { + const room = makeRoom(); + roomHandlers.self_apply_free_room({ room }, {} as any); + roomHandlers.self_refuse_free_room({ room, player: makePlayer() }, {} as any); + assert.equal(room.state.value.state, 1); +}); + +test('other_offline: 触发但不破坏 RoomStore(offline 状态由子游戏持有)', () => { + const room = makeRoom(); + // RoomStore 不持有 offline 字段(handler 应是 no-op 或仅校验入参) + roomHandlers.other_offline({ room }, { seat: 1 } as any); + // state 不变 + assert.equal(room.state.value.state, 0); +}); + +test('other_online: 触发但不破坏 RoomStore', () => { + const room = makeRoom(); + roomHandlers.other_online({ room }, { seat: 1 } as any); + assert.equal(room.state.value.state, 0); +}); + +test('change_seat: 交换两个座位(seat=1 ↔ seat=3)', () => { + const room = makeRoom(); + // 初始: players = [null, 100, 200, null] + // change_seat(from=1, to=3) → i_from=0, i_to=2 + // swap: [null, 100, 200, null] ↔ [200, 100, null, null] + roomHandlers.change_seat({ room }, { from: 1, to: 3 } as any); + const v = room.state.value; + // swap 结果:players[0]=200, players[1]=100, players[2]=null, players[3]=null + assert.equal(v.players[0]?.playerid, 200, '原 seat=3 的 200 现在在 seat=1(0基=0)'); + assert.equal(v.players[1]?.playerid, 100, '原 seat=2 的 100 仍在原位'); + assert.equal(v.players[2], null, '原 seat=1 的 null 现在在 seat=3(0基=2)'); +}); + +test('free_room: 解散投票结束 → state=2(已解散,业务侧清场)', () => { + const room = makeRoom(); + roomHandlers.self_apply_free_room({ room }, {} as any); + roomHandlers.free_room({ room }, { result: 1 } as any); + // state 字段语义: 0=正常 1=投票中 2=已解散 + assert.equal(room.state.value.state, 2); +}); + +test('unknown rpc 不在 handler 表里 → 显式抛错(第二准则)', () => { + const room = makeRoom(); + const h = roomHandlers as unknown as Record void>; + // Object.freeze 后,h['unknown_rpc'] 是 undefined,调用会抛 + assert.throws(() => h['unknown_rpc']({ room, player: makePlayer() }, {}), /unknown_rpc/); +}); \ No newline at end of file