Rules
斗地主插件注册到 RuleEngine 的 9 条校验规则,覆盖阶段、轮次、牌权、所属权、牌型、PASS、叫 / 抢地主等校验。
概述
每条 Rule 实现 Rule 接口,包含:
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | 规则唯一标识符(如 'ddz-phase') |
name | string | 人类可读名称 |
appliesTo | string | '*' | string[] | 适用的 Action 类型;'*' 表示全部 |
validate(c) | (c: RuleContext) => RuleResult | 校验函数,返回 ok() 或 deny(reason, code) |
DoudizhuPlugin.rules 数组的注册顺序决定短路顺序:基础校验(阶段、轮次)在前,业务规则(叫 / 抢地主)在后。任一规则 deny 即拒绝 Action。
规则一览
| 序号 | Rule id | 适用 Action | 说明 |
|---|---|---|---|
| 1 | ddz-game-not-won | * | 游戏未结束 |
| 2 | ddz-game-started | * | 游戏已开始 |
| 3 | ddz-phase | * | Action 类型与当前阶段匹配 |
| 4 | ddz-player-turn | * | 当前玩家匹配(系统 Action 豁免) |
| 5 | ddz-player-owns-cards | PLAY_CARDS | 玩家持有 payload 中所有卡牌 |
| 6 | ddz-combination-valid | PLAY_CARDS | 牌型合法且能压过上一手 |
| 7 | ddz-pass | PASS | 新轮次首出不允许 PASS |
| 8 | ddz-call-landlord | CALL_LANDLORD | 叫分人轮次匹配 |
| 9 | ddz-rob-landlord | ROB_LANDLORD | 抢地主者轮次匹配 |
DoudizhuPhaseRule(阶段与轮次规则)
来源:rules/DoudizhuPhaseRule.ts,导出 4 条规则与一个系统 Action 集合。
SYSTEM_ACTIONS
const SYSTEM_ACTIONS = new Set<string>([REVEAL_BOTTOM_CARDS_ACTION])文件内部的常量(未导出),列出所有系统派发的 Action(目前仅 REVEAL_BOTTOM_CARDS)。这些 Action 被 doudizhuPhaseRule 与 doudizhuPlayerTurnRule 豁免。
doudizhuGameStartedRule
const doudizhuGameStartedRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-game-started' |
name | 'Doudizhu Game Started' |
appliesTo | '*' |
校验逻辑
c.state.status === 'playing'→ok()- 否则 →
deny('doudizhu: game has not started', 'NOT_STARTED')
强制 Engine 整体状态为 'playing'(游戏已开始、未结束)。
doudizhuGameNotWonRule
const doudizhuGameNotWonRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-game-not-won' |
name | 'Doudizhu Game Not Won' |
appliesTo | '*' |
校验逻辑
c.state.winnerIds.length === 0→ok()- 否则 →
deny('doudizhu: game already won', 'ALREADY_WON')
doudizhuPhaseRule
const doudizhuPhaseRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-phase' |
name | 'Doudizhu Phase' |
appliesTo | '*' |
校验逻辑
Action 类型映射到阶段如下:
| Action | 要求阶段 |
|---|---|
CALL_LANDLORD | BIDDING |
ROB_LANDLORD | BIDDING |
PLAY_CARDS | PLAYING |
PASS | PLAYING |
REVEAL_BOTTOM_CARDS | 豁免(系统 Action) |
- 若
c.action.type ∈ SYSTEM_ACTIONS→ok()(直接放行) - 若
t === CALL_LANDLORD_ACTION || t === ROB_LANDLORD_ACTION→ 要求phase === BIDDING,否则deny(..., 'WRONG_PHASE') - 若
t === PLAY_CARDS_ACTION || t === PASS_ACTION→ 要求phase === PLAYING,否则deny(..., 'WRONG_PHASE') - 其它 Action →
ok()
deny 消息格式:doudizhu: ${t} requires ${REQUIRED} phase (current: ${phase})
doudizhuPlayerTurnRule
const doudizhuPlayerTurnRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-player-turn' |
name | 'Doudizhu Player Turn' |
appliesTo | '*' |
校验逻辑
- 若
c.action.type ∈ SYSTEM_ACTIONS→ok()(系统 Action 无 playerId) - 若
!c.action.playerId→deny('doudizhu: action has no playerId', 'NO_PLAYER') - 若
c.state.currentPlayerId !== c.action.playerId→deny('doudizhu: not ${playerId}\'s turn (current: ${current})', 'NOT_YOUR_TURN') - 否则 →
ok()
PlayCardsRule(牌权与卡牌归属)
来源:rules/PlayCardsRule.ts,导出 1 条规则与 1 个常量。
DDZ_MAX_PLAYED_CARDS
const DDZ_MAX_PLAYED_CARDS = 20单次 PLAY_CARDS Action 允许的最大卡牌数。地主最多持 20 张牌(17 + 3 底牌),故此上限覆盖地主一次性打完所有牌的场景。
playerOwnsCardsRule
const playerOwnsCardsRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-player-owns-cards' |
name | 'Doudizhu Player Owns Cards' |
appliesTo | ['PLAY_CARDS'] |
校验逻辑
cardIds.length === 0→deny('doudizhu: no cards to play', 'NO_CARDS')cardIds.length > DDZ_MAX_PLAYED_CARDS→deny('doudizhu: too many cards (${len} > 20)', 'TOO_MANY_CARDS')!c.action.playerId→deny('doudizhu: no player', 'NO_PLAYER')- 读取玩家手牌 zone(
player:{playerId}:hand),若不存在 →deny('doudizhu: player has no hand', 'NO_HAND') - 对每个
cardId,若不在手牌 zone 中 →deny('doudizhu: player does not own card ${id}', 'NOT_OWNED') - 若
cardIds存在重复 id →deny('doudizhu: duplicate card ids in payload', 'DUPLICATE_CARDS') - 否则 →
ok()
CombinationRule(牌型校验)
来源:rules/CombinationRule.ts,导出 1 条规则、1 个变量键与 1 个辅助函数。
DDZ_VAR_PENDING_COMBINATION
const DDZ_VAR_PENDING_COMBINATION = 'ddz.pendingCombination'PLAY_CARDS 牌型校验的结果暂存键。combinationValidRule 在校验通过后将检测到的牌型写入 c.game 的此变量下,PlayCards 处理器再读回使用,避免重新运行检测器。该变量在每次派发开始时由 Action 处理器清空。
combinationValidRule
const combinationValidRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-combination-valid' |
name | 'Doudizhu Combination Valid' |
appliesTo | ['PLAY_CARDS'] |
校验逻辑
- 读取
cardIds(空时返回ok()——已被playerOwnsCardsRule拒绝); - 若
!c.action.playerId→ok()(交给前序规则处理); - 从
c.state.cards收集cardIds对应的Card[],任一不存在 →deny('doudizhu: card ${id} not in state', 'CARD_NOT_FOUND'); - 调用
detectDoudizhuCombination(cards):- 若
detected.type === UNKNOWN→deny('doudizhu: cards do not form a recognized combination', 'INVALID_COMBINATION')
- 若
- 读取
last = getLastCombination(c.state):- 若
last === undefined(新一轮)→ 允许任意合法牌型,写入DDZ_VAR_PENDING_COMBINATION = detected,返回ok();
- 若
- 否则调用
new DoudizhuCombinationComparator().compare(detected, last):result === GREATER→ 写入 pending,ok()result === EQUAL→deny('doudizhu: combination does not strictly beat the last play (equal)', 'DOES_NOT_BEAT')- 否则(
INCOMPARABLE或LESS)→deny('doudizhu: combination does not beat the last play (${incomparable|weaker})', 'DOES_NOT_BEAT')
detectPlayable()
function detectPlayable(
cards: Card[],
last: DoudizhuCombination | undefined
): { combination: DoudizhuCombination; beats: boolean } | null测试使用的便捷辅助函数:不经过 RuleEngine 直接完成检测 + 比较。
参数
cards: Card[]— 待检测的卡牌last: DoudizhuCombination | undefined— 上一手牌型(新一轮为undefined)
返回值
{ combination: DoudizhuCombination; beats: boolean } | null— 若 cards 无法构成合法牌型返回null;否则返回牌型对象与是否可压过 last 的布尔值。
示例
import { detectPlayable } from '../../rules/CombinationRule.js'
const result = detectPlayable([card3, card3_2], undefined)
// 若两张牌同点数 → { combination: <PAIR>, beats: true }PassRule(过牌规则)
来源:rules/PassRule.ts,导出 1 条规则。
doudizhuPassRule
const doudizhuPassRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-pass' |
name | 'Doudizhu Pass' |
appliesTo | ['PASS'] |
校验逻辑
- 若
isNewRound(c.state)(即lastCombination === undefined)→deny('doudizhu: cannot PASS on the first play of a round', 'CANNOT_PASS_FIRST') - 否则 →
ok()
新轮次 / 本轮首出时当前玩家不能过牌,必须出牌。"连续两次过牌重置当前轮次"的行为由 PassEffect 强制执行,而非此规则。
CallLandlordRule(叫地主规则)
来源:rules/CallLandlordRule.ts,导出 1 条规则与 1 个辅助函数。
callLandlordRule
const callLandlordRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-call-landlord' |
name | 'Doudizhu Call Landlord' |
appliesTo | ['CALL_LANDLORD'] |
校验逻辑
- 读取
payload.call:typeof payload.call !== 'boolean'→deny('doudizhu: CALL_LANDLORD payload.call must be boolean', 'INVALID_PAYLOAD')
!c.action.playerId→deny('doudizhu: no player', 'NO_PLAYER')- 读取
ddz = getDoudizhuState(c.state):ddz.landlordCandidateId !== undefined→deny('doudizhu: landlord candidate already set; use ROB_LANDLORD', 'CANDIDATE_SET')
- 按座位顺序计算
expected = ordered[ddz.bidderIndex % ordered.length]?.id:c.action.playerId !== expected→deny('doudizhu: not ${playerId}\'s bid turn (expected ${expected})', 'NOT_YOUR_BID')
- 否则 →
ok()
若地主候选人已确定(先前有人叫地主),在简化的"首位叫地主者胜出"流程中不允许后续叫地主——后续玩家必须等待 ROB_LANDLORD。
nextBidderId()
function nextBidderId(state: Parameters<typeof getDoudizhuState>[0]): string | undefined便捷方法:根据 GameState 计算下一位叫分人 id。导出供 CallLandlord 副作用与测试使用相同的轮换逻辑。
参数
state: GameState— 游戏状态
返回值
string | undefined— 下一位叫分人 id。ordered.length === 0时返回undefined。
行为
读取 DDZ_VAR_BIDDER_INDEX(默认 0),按座位顺序取 ordered[idx % ordered.length]?.id。
RobLandlordRule(抢地主规则)
来源:rules/RobLandlordRule.ts,导出 1 条规则。
robLandlordRule
const robLandlordRule: Rule| 字段 | 值 |
|---|---|
id | 'ddz-rob-landlord' |
name | 'Doudizhu Rob Landlord' |
appliesTo | ['ROB_LANDLORD'] |
校验逻辑
typeof payload.rob !== 'boolean'→deny('doudizhu: ROB_LANDLORD payload.rob must be boolean', 'INVALID_PAYLOAD')!c.action.playerId→deny('doudizhu: no player', 'NO_PLAYER')ddz.landlordCandidateId === undefined→deny('doudizhu: no landlord candidate to rob', 'NO_CANDIDATE')c.action.playerId === ddz.landlordCandidateId→deny('doudizhu: candidate cannot rob themselves', 'SELF_ROB')- 在
ordered中找candidateIdx:candidateIdx === -1→deny('doudizhu: landlord candidate not in seat order', 'BAD_STATE')
- 计算
nextId = ordered[(candidateIdx + 1) % ordered.length]?.id(候选人之后的下一位座位为下一位叫分人):c.action.playerId !== nextId→deny('doudizhu: not ${playerId}\'s rob turn (expected ${nextId})', 'NOT_YOUR_ROB')
- 否则 →
ok()
Phase 5 默认叫分流程不使用 ROB_LANDLORD——首位叫地主者立即胜出。仍然注册此规则,是为了让后续完整的叫分流程可以接入而无需改动 Plugin 对外接口。
WinRule(胜负判定)
来源:rules/WinRule.ts,导出 1 个函数与 1 个空 Rule 数组。
determineWinnerTeam()
function determineWinnerTeam(
state: GameState,
winnerId: string
): DoudizhuTeam | undefined判定已结束的斗地主游戏的获胜队伍。
参数
state: GameState— 游戏状态winnerId: string— 清空手牌的玩家 id
返回值
DoudizhuTeam | undefined— 获胜队伍:winnerId === landlordId→DoudizhuTeam.LANDLORDwinnerId ∈ farmers→DoudizhuTeam.FARMERSlandlordId === undefined→undefined(无法判定)- 其它 →
teamOf(winnerId, state)
行为
- 若地主清空手牌 → 地主(LANDLORD)获胜;
- 若任一农民清空手牌 → 农民(FARMERS)获胜;
- 若游戏未结束或无法判定获胜方则返回
undefined。
doudizhuWinRules
const doudizhuWinRules: Rule[]空数组。WinRule 并非 validate() 意义上的 Rule——斗地主的胜利判定发生在 PLAY_CARDS 处理器执行之后(Engine 没有 "post-action" 规则钩子)。实际的胜利判定在 DoudizhuPlugin 的 CARD_PLAYED 事件处理器中完成(调用 PlayCardsEffect.declareWin)。此处导出该辅助函数是为了让测试可以直接调用,无需导入 Plugin 内部。为了与 UNO Plugin 的 API 对称,仍然保留一个空的 Rule 数组占位。
完整校验链路(PLAY_CARDS 为例)
engine.dispatch(createPlayCardsAction(playerId, cardIds))
│
▼
RuleEngine 按注册顺序短路校验:
│
├─ 1. doudizhuGameNotWonRule → winnerIds 必须为空
├─ 2. doudizhuGameStartedRule → state.status === 'playing'
├─ 3. doudizhuPhaseRule → phase === PLAYING
├─ 4. doudizhuPlayerTurnRule → currentPlayerId === playerId
├─ 5. playerOwnsCardsRule → cardIds 非空、≤20、玩家持有、无重复
└─ 6. combinationValidRule → 牌型合法且可压过 lastCombination
└─ 检测到的牌型写入 DDZ_VAR_PENDING_COMBINATION
│
▼ (全部 ok)
playCardsHandler 读取 pending 牌型 → 移牌、更新 lastCombination、倍率 ×2、发出事件
│
▼
EventBus 派发 CARD_PLAYED → handleCardsPlayed → applyPlayCardsSideEffects
│
├─ 手牌空 → declareWin
└─ 否则 → dispatch(END_TURN)注意事项
combinationValidRule中"检测一次存储到 state"是一个务实的简化——另一种做法是在处理器中重新检测,可行但 CPU 开销翻倍。Phase 5 选择"检测一次存储"方案,DDZ_VAR_PENDING_COMBINATION在每次派发开始时由 Action 处理器清空(removeVariable)。playerOwnsCardsRule中若 payload 有重复 id,会先通过NOT_OWNED(第二次找不到)或显式DUPLICATE_CARDS拒绝——代码注释提到"payload 中若有重复 id,会静默地允许复用同一张卡牌",但实际实现紧接着用unique.size !== cardIds.length显式拒绝重复,故不会静默允许。- 所有 deny 的 reason code(如
'NOT_YOUR_TURN'、'WRONG_PHASE'、'DOES_NOT_BEAT'等)均为大写下划线字符串,便于上层逻辑匹配。