棋牌游戏开发实战(十):机器人策略接入与合法动作校验
摘要:机器人可以提出动作,但不能直接修改房间状态。本文用 JavaScript 示例说明策略输入、合法动作列表、超时回退和执行前复核,明确演示代码的使用边界。
机器人接进牌桌以后,最先要保证的不是它打得多聪明,而是它不能绕开正常出牌流程。如果真人出牌需要检查轮次和手牌,机器人却直接修改数组,就等于房间里同时存在两套规则入口。开始时很方便,后续加入新玩法、托管和回放,问题就会从这些差异里冒出来。
我会把策略和执行分开。策略拿到允许看到的局面,给出一个动作建议;服务端根据当前状态决定是否接受。规则模块负责生成合法动作列表,策略只能从列表里选择,最终执行前还要检查轮次有没有变化。这样即使策略出错,也有清楚的拦截位置。

配图为 AI 辅助制作的游戏 UI 概念图,展示练习伙伴正在操作、当前玩家等待的界面,不是本文示例运行后的实测截图。图中卡牌仅表现场景,本文不实现图示扑克玩法的完整规则。
下面的代码接收 view、allowed 和 strategy。view 应由服务端按机器人所在座位生成,不包含其他玩家隐藏手牌、未公开牌墙和内部管理信息。allowed 则由服务端规则模块生成,策略返回动作编号,执行方从原始列表取回规范动作,不信任策略自己填的牌值。
export async function chooseBotAction(view, allowed, strategy, timeoutMs = 20) {
if (!allowed.length) throw Error('NO_LEGAL_ACTION');
const copy = value => JSON.parse(JSON.stringify(value));
let timer;
try {
const proposal = await Promise.race([
Promise.resolve().then(() => strategy(copy(view), copy(allowed))),
new Promise((_, reject) => { timer = setTimeout(() => reject(Error('TIMEOUT')), timeoutMs); })
]);
const found = allowed.find(a => a.id === proposal?.id);
return copy(found ?? allowed[0]);
} catch {
return copy(allowed[0]);
} finally { clearTimeout(timer); }
}
export function commitAction(state, expectedTurn, actionId) {
if (state.turn !== expectedTurn || state.done) return {ok:false, reason:'STALE'};
const legal = state.allowed.find(a => a.id === actionId);
if (!legal) return {ok:false, reason:'ILLEGAL'};
state.done = true;
return {ok:true, event:{turn:state.turn, action:JSON.parse(JSON.stringify(legal))}};
}
示例把动作描述限制为可以通过 JSON 表达的普通数据。复制方法不支持 BigInt、循环引用和函数,不能拿它复制任意工程对象。复制的目的只是避免普通策略代码意外改写输入,不是安全沙箱。恶意或未知代码应放在受限制的独立执行环境中,不能因为传了一份对象副本就认为安全。
allowed 中的动作编号必须在本次决策内唯一,并与当前回合绑定。如果同一个编号对应两种不同动作,find 只会拿到第一项,策略结果就没有稳定含义。正式接入前应检查列表结构、重复编号和动作参数,示例假定这些工作已经由规则模块完成。
机器人返回 play-1,同时附带 tile:99,也不能把 99 带进房间。chooseBotAction 只用编号查找,返回服务端原本允许的那个动作。如果编号不存在,或者策略抛错,就选择列表第一项作为演示回退。这里的第一项必须由规则模块安排为允许执行的保底动作,不能在没有合法动作时随便造一个跳过事件。
超时使用 Promise.race,并在结束后清理计时器。它只是不再等待策略结果,不会终止策略内部正在执行的工作。如果策略陷入同步死循环,事件循环也会被阻塞,这个定时器救不了它。需要严格时间预算时,应在独立 worker 或进程中执行策略,并设计终止机制、资源限制和失败隔离。本文代码适合可信、异步且有界的演示策略,不适合直接运行陌生脚本。
const allowed = [
{ id: 'pass', type: 'PASS' },
{ id: 'play-1', type: 'PLAY', tile: 1 }
];
const proposal = await chooseBotAction(
{ myHand: [1, 2], publicDiscards: [3] },
allowed,
async (_view, actions) => ({ id: actions[1].id })
);
// 等待策略期间可能已经换回合,提交时还要复核。
const state = { turn: 3, done: false, allowed };
const result = commitAction(state, 3, proposal.id);
这段调用只是说明接口,PASS 和 PLAY 是演示动作,不能直接当成某个实际玩法的合法集合。真正的合法列表取决于阶段、轮次、手牌和规则版本。把“有这张牌”当成唯一条件往往不够,某些阶段只能回应某个动作,有些动作还需要检查组合或先前状态。
策略可能比较慢。在等待期间,用户取消托管、房间关闭、回合被其他事件推进,都可能让旧结果过期。commitAction 的 expectedTurn 就是最后一道轮次检查。正式房间如果允许同一轮次内的状态继续改变,还要加状态版本或决策编号,不能只比较一个过粗的轮次字段。
示例的提交过程不包含 await,且依赖一个进程里的顺序调用。它没有数据库事务、多节点一致性或持久化恢复。真实系统应该把机器人动作也送进普通房间命令队列,使用与真人操作相同的版本校验和事件提交机制。不要另写一个能跳过身份、轮次或规则检查的内部接口。
机器人与真人可以共用规则执行入口,但身份来源仍要区分。服务端为机器人生成身份和座位关系,不能允许客户端传一个 bot:true 就取得内部权限。执行事件可以记录策略版本、决策编号和动作来源,回放仍保存最终已经接受的规范动作,而不是把策略完整输入原样广播。
策略调试日志尤其要注意隐藏信息。为了分析机器人为什么这样选,可以记录允许公开的局面摘要、候选动作和错误类型;不要把其他座位的私有状态顺手写进公开日志或客户端返回。即便机器人逻辑在服务端运行,数据仍可能从诊断接口泄漏。
测试也应分开看。本文检查了返回动作被规范化、非法编号回退、策略异常、异步等待超时、输入不被普通修改污染,以及旧回合和重复提交被拒绝。测试没有证明机器人能赢,也没有完整玩法合法性测试、进程隔离测试或 Cocos 接入。策略水平需要另外评估,不能用规则入口检查通过代替胜率结论。
接入时可以先使用固定动作选择器,让每一条消息都能追踪,再逐步替换成评分策略或更复杂的模型。如果一开始就把网络调用、规则判断和直接改牌桌揉在一个函数里,出现异常时很难判断是策略没返回、列表过期,还是房间已经换了回合。
机器人策略可以不断换,执行边界应保持稳定。让它看到正确的视角、从当前合法集合里选择、在执行前再次确认状态,并通过正常事件链路推进牌桌,后续再提高策略质量,才不会把越来越复杂的决策变成房间里的特殊通道。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)