feat(framework): 房间事件 handler(self/other join/exit/free_room/change_seat 等)
完整覆盖 C 规范 §6.4 对局流程的 14 个 room rpc: - self_join_room / other_join_room(填对应座位) - self_exit_room / other_exit_room(清空座位) - self/other_apply_free_room(state=1 投票中) - self/other_agree_free_room / refuse_free_room(状态由其他协议累计,本层 no-op) - other_offline / other_online(RoomStore 不持有,handler no-op) - change_seat(swap 两个座位) - free_room(state=2 已解散) 设计要点: - 纯逻辑函数 (ctx, data) => void,单测直接调,不依赖 NetClient/Router - 1 基座位 → 0 基数组下标;越界显式抛错(第二准则) - Object.freeze(handler表) 防止外部写入不安全 handler - 未注册 rpc 显式抛错(第二准则) - RoomState 增加 state 字段(0/1/2 三态:正常/投票中/已解散) 158/158 tests pass, typecheck exit 0。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>;
|
||||
|
||||
/** 单个 handler 的签名 */
|
||||
export type RoomHandler = (ctx: RoomHandlerContext, data: RoomRpcData) => void;
|
||||
|
||||
/**
|
||||
* 把任意 rpc 名映射到对应 handler;未注册的显式抛错(第二准则:不静默吞包)。
|
||||
*
|
||||
* 为避免构造后被外部写入不安全 handler,采用 Object.freeze。
|
||||
*/
|
||||
function defineHandlers(table: Record<string, RoomHandler>): Readonly<Record<string, RoomHandler>> {
|
||||
const wrapped: Record<string, RoomHandler> = {};
|
||||
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;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user