Skip to content

Rules ​

斗地主插件注册到 RuleEngine 的 9 条校验规则,覆盖阶段、轮次、牌权、所属权、牌型、PASS、叫 / 抢地主等校验。

概述 ​

每条 Rule 实现 Rule 接口,包含:

字段类型说明
idstring规则唯一标识符(如 'ddz-phase')
namestring人类可读名称
appliesTostring | '*' | string[]适用的 Action 类型;'*' 表示全部
validate(c)(c: RuleContext) => RuleResult校验函数,返回 ok() 或 deny(reason, code)

DoudizhuPlugin.rules 数组的注册顺序决定短路顺序:基础校验(阶段、轮次)在前,业务规则(叫 / 抢地主)在后。任一规则 deny 即拒绝 Action。

规则一览 ​

序号Rule id适用 Action说明
1ddz-game-not-won*游戏未结束
2ddz-game-started*游戏已开始
3ddz-phase*Action 类型与当前阶段匹配
4ddz-player-turn*当前玩家匹配(系统 Action 豁免)
5ddz-player-owns-cardsPLAY_CARDS玩家持有 payload 中所有卡牌
6ddz-combination-validPLAY_CARDS牌型合法且能压过上一手
7ddz-passPASS新轮次首出不允许 PASS
8ddz-call-landlordCALL_LANDLORD叫分人轮次匹配
9ddz-rob-landlordROB_LANDLORD抢地主者轮次匹配

DoudizhuPhaseRule(阶段与轮次规则) ​

来源:rules/DoudizhuPhaseRule.ts,导出 4 条规则与一个系统 Action 集合。

SYSTEM_ACTIONS ​

ts
const SYSTEM_ACTIONS = new Set<string>([REVEAL_BOTTOM_CARDS_ACTION])

文件内部的常量(未导出),列出所有系统派发的 Action(目前仅 REVEAL_BOTTOM_CARDS)。这些 Action 被 doudizhuPhaseRule 与 doudizhuPlayerTurnRule 豁免。

doudizhuGameStartedRule ​

ts
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 ​

ts
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 ​

ts
const doudizhuPhaseRule: Rule
字段值
id'ddz-phase'
name'Doudizhu Phase'
appliesTo'*'

校验逻辑 ​

Action 类型映射到阶段如下:

Action要求阶段
CALL_LANDLORDBIDDING
ROB_LANDLORDBIDDING
PLAY_CARDSPLAYING
PASSPLAYING
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 ​

ts
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 ​

ts
const DDZ_MAX_PLAYED_CARDS = 20

单次 PLAY_CARDS Action 允许的最大卡牌数。地主最多持 20 张牌(17 + 3 底牌),故此上限覆盖地主一次性打完所有牌的场景。

playerOwnsCardsRule ​

ts
const playerOwnsCardsRule: Rule
字段值
id'ddz-player-owns-cards'
name'Doudizhu Player Owns Cards'
appliesTo['PLAY_CARDS']

校验逻辑 ​

  1. cardIds.length === 0 → deny('doudizhu: no cards to play', 'NO_CARDS')
  2. cardIds.length > DDZ_MAX_PLAYED_CARDS → deny('doudizhu: too many cards (${len} > 20)', 'TOO_MANY_CARDS')
  3. !c.action.playerId → deny('doudizhu: no player', 'NO_PLAYER')
  4. 读取玩家手牌 zone(player:{playerId}:hand),若不存在 → deny('doudizhu: player has no hand', 'NO_HAND')
  5. 对每个 cardId,若不在手牌 zone 中 → deny('doudizhu: player does not own card ${id}', 'NOT_OWNED')
  6. 若 cardIds 存在重复 id → deny('doudizhu: duplicate card ids in payload', 'DUPLICATE_CARDS')
  7. 否则 → ok()

CombinationRule(牌型校验) ​

来源:rules/CombinationRule.ts,导出 1 条规则、1 个变量键与 1 个辅助函数。

DDZ_VAR_PENDING_COMBINATION ​

ts
const DDZ_VAR_PENDING_COMBINATION = 'ddz.pendingCombination'

PLAY_CARDS 牌型校验的结果暂存键。combinationValidRule 在校验通过后将检测到的牌型写入 c.game 的此变量下,PlayCards 处理器再读回使用,避免重新运行检测器。该变量在每次派发开始时由 Action 处理器清空。

combinationValidRule ​

ts
const combinationValidRule: Rule
字段值
id'ddz-combination-valid'
name'Doudizhu Combination Valid'
appliesTo['PLAY_CARDS']

校验逻辑 ​

  1. 读取 cardIds(空时返回 ok()——已被 playerOwnsCardsRule 拒绝);
  2. 若 !c.action.playerId → ok()(交给前序规则处理);
  3. 从 c.state.cards 收集 cardIds 对应的 Card[],任一不存在 → deny('doudizhu: card ${id} not in state', 'CARD_NOT_FOUND');
  4. 调用 detectDoudizhuCombination(cards):
    • 若 detected.type === UNKNOWN → deny('doudizhu: cards do not form a recognized combination', 'INVALID_COMBINATION')
  5. 读取 last = getLastCombination(c.state):
    • 若 last === undefined(新一轮)→ 允许任意合法牌型,写入 DDZ_VAR_PENDING_COMBINATION = detected,返回 ok();
  6. 否则调用 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() ​

ts
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 的布尔值。

示例 ​

ts
import { detectPlayable } from '../../rules/CombinationRule.js'

const result = detectPlayable([card3, card3_2], undefined)
// 若两张牌同点数 → { combination: <PAIR>, beats: true }

PassRule(过牌规则) ​

来源:rules/PassRule.ts,导出 1 条规则。

doudizhuPassRule ​

ts
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 ​

ts
const callLandlordRule: Rule
字段值
id'ddz-call-landlord'
name'Doudizhu Call Landlord'
appliesTo['CALL_LANDLORD']

校验逻辑 ​

  1. 读取 payload.call:
    • typeof payload.call !== 'boolean' → deny('doudizhu: CALL_LANDLORD payload.call must be boolean', 'INVALID_PAYLOAD')
  2. !c.action.playerId → deny('doudizhu: no player', 'NO_PLAYER')
  3. 读取 ddz = getDoudizhuState(c.state):
    • ddz.landlordCandidateId !== undefined → deny('doudizhu: landlord candidate already set; use ROB_LANDLORD', 'CANDIDATE_SET')
  4. 按座位顺序计算 expected = ordered[ddz.bidderIndex % ordered.length]?.id:
    • c.action.playerId !== expected → deny('doudizhu: not ${playerId}\'s bid turn (expected ${expected})', 'NOT_YOUR_BID')
  5. 否则 → ok()

若地主候选人已确定(先前有人叫地主),在简化的"首位叫地主者胜出"流程中不允许后续叫地主——后续玩家必须等待 ROB_LANDLORD。

nextBidderId() ​

ts
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 ​

ts
const robLandlordRule: Rule
字段值
id'ddz-rob-landlord'
name'Doudizhu Rob Landlord'
appliesTo['ROB_LANDLORD']

校验逻辑 ​

  1. typeof payload.rob !== 'boolean' → deny('doudizhu: ROB_LANDLORD payload.rob must be boolean', 'INVALID_PAYLOAD')
  2. !c.action.playerId → deny('doudizhu: no player', 'NO_PLAYER')
  3. ddz.landlordCandidateId === undefined → deny('doudizhu: no landlord candidate to rob', 'NO_CANDIDATE')
  4. c.action.playerId === ddz.landlordCandidateId → deny('doudizhu: candidate cannot rob themselves', 'SELF_ROB')
  5. 在 ordered 中找 candidateIdx:
    • candidateIdx === -1 → deny('doudizhu: landlord candidate not in seat order', 'BAD_STATE')
  6. 计算 nextId = ordered[(candidateIdx + 1) % ordered.length]?.id(候选人之后的下一位座位为下一位叫分人):
    • c.action.playerId !== nextId → deny('doudizhu: not ${playerId}\'s rob turn (expected ${nextId})', 'NOT_YOUR_ROB')
  7. 否则 → ok()

Phase 5 默认叫分流程不使用 ROB_LANDLORD——首位叫地主者立即胜出。仍然注册此规则,是为了让后续完整的叫分流程可以接入而无需改动 Plugin 对外接口。


WinRule(胜负判定) ​

来源:rules/WinRule.ts,导出 1 个函数与 1 个空 Rule 数组。

determineWinnerTeam() ​

ts
function determineWinnerTeam(
  state: GameState,
  winnerId: string
): DoudizhuTeam | undefined

判定已结束的斗地主游戏的获胜队伍。

参数 ​

  • state: GameState — 游戏状态
  • winnerId: string — 清空手牌的玩家 id

返回值 ​

  • DoudizhuTeam | undefined — 获胜队伍:
    • winnerId === landlordId → DoudizhuTeam.LANDLORD
    • winnerId ∈ farmers → DoudizhuTeam.FARMERS
    • landlordId === undefined → undefined(无法判定)
    • 其它 → teamOf(winnerId, state)

行为 ​

  • 若地主清空手牌 → 地主(LANDLORD)获胜;
  • 若任一农民清空手牌 → 农民(FARMERS)获胜;
  • 若游戏未结束或无法判定获胜方则返回 undefined。

doudizhuWinRules ​

ts
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' 等)均为大写下划线字符串,便于上层逻辑匹配。