返回 小玩具
小玩具
15 分钟阅读

锄大地计分器:规则引擎与链上记分方案

基于 Python + Excel 的锄大地(Big Two)计分系统,含规则建模、数据校验与 Solidity 链上扩展

概述

锄大地(Big Two) 是流行于广东地区的四人扑克牌游戏,规则以「先出完牌者通吃」为核心。手动记账在连局对战中容易算错倍数、遗漏抓 2 惩罚,且难以实时汇总累计输赢。

本项目实现了一套可配置的计分引擎:对局结束后录入各玩家剩余牌数与被抓 2 数量,自动计算单局输赢与累计金额。当前形态为 Python CLI + Excel 持久化;后续可扩展 Web / Mobile 前端,或通过 Solidity 智能合约实现不可篡改的链上记分。

二次元猫耳朋友聚会:参考 cat1 风格的拟人化小猫围桌打锄大地,旁有几只小猫围观,笔记本运行计分辅助程序

游戏规则(计分模型)

基本约定

  • 四人局,每人 13 张牌,每局有且仅有一个赢家(剩余牌数 = 0)。
  • 赢家分数 = 所有输家本局罚分之和(零和博弈)。
  • 输家罚分由三个因子相乘:剩余牌数 × 牌数倍数 × 抓 2 倍数 × 单价

牌数倍数(Card Multiplier)

剩余牌数倍数
13(一张未出)×4
10 – 12×3
8 – 9×2
0 – 7×1

抓 2 倍数(Power Multiplier)

输家手中剩余的 2 按张数指数翻倍:

M_2 = 2^n    (n = 剩余 2 的张数)
剩余 2 张数倍数
0×1
1×2
2×4
3×8
4×16

计分公式

对单个输家:

Loss = R × M_card(R) × 2^n × P

其中 R 为剩余牌数,n 为剩余 2 张数,P 为单张牌单价(元),M_card 为牌数倍数查表函数。

示例((P = 0.5) 元/张):

玩家剩余牌剩余 2计算结果
A(输)10210 × 3 × 2² × 0.5−60 元
B(输)919 × 2 × 2¹ × 0.5−18 元
C(输)606 × 1 × 2⁰ × 0.5−3 元
D(赢)00吸收全部输分+81 元

系统架构

┌──────────────┐    录入每局数据     ┌─────────────────┐
│  对局结束     │ ────────────────► │  play-2d.xlsx   │
│  (人工记录)   │   Remain / Remain2│  (Excel 持久层)  │
└──────────────┘                   └────────┬────────┘
                                            │ pandas.read_excel
                                            ▼
                                   ┌─────────────────┐
                                   │  main.py        │
                                   │  · 数据校验      │
                                   │  · 规则引擎计算   │
                                   │  · 累计汇总输出   │
                                   └────────┬────────┘
                                            │
              ┌─────────────────────────────┼─────────────────────────────┐
              ▼                             ▼                             ▼
        CLI 终端输出                  Web (Flask)                   链上 (Solidity)
        当前实现                      可选扩展                       可选扩展

设计取舍:Excel 宽表(每玩家两列)适合快速录入,但不适合关系型数据库范式。作为个人 Side Project 可接受;若迁移至 Web / Mobile,建议改用 SQLite 并按 rounds + round_players 规范化建模。

数据模型

Excel 宽表结构

RoundA RemainA Remain 2B RemainB Remain 2C RemainC Remain 2D RemainD Remain 2
100312041
230729100

每行代表一局;Remain 为剩余牌数,Remain 2 为剩余 2 的张数。赢家行对应 Remain = 0

校验规则

引擎在计算前对每行数据做完整性校验,任一失败则终止全流程:

校验项条件
赢家唯一性恰好 1 名玩家 Remain = 0
牌数范围0 ≤ Remain ≤ 13
2 的数量0 ≤ Remain 2 ≤ Remain
全局 2 上限四人 Remain 2 之和 ≤ 4(一副牌共 4 张 2)
类型检查所有字段可解析为整数

实现:配置层

规则参数集中在 config.py,支持自定义玩家名称、单价与倍数区间:

# config.py
EXCEL_FILE_PATH = "./play-2d.xlsx"
CARD_PRICE = 0.5
 
PLAYERS_CONFIG = {
    "A": "APlayer",
    "B": "BPlayer",
    "C": "CPlayer",
    "D": "DPlayer"
}
 
CARD_MULT_RULES = [
    (range(13, 14), 4),     # 剩 13 张 → ×4
    (range(10, 13), 3),     # 剩 10–12 张 → ×3
    (range(8, 10), 2),      # 剩 8–9 张 → ×2
    (range(0, 8), 1),       # 剩 0–7 张 → ×1
]
 
def get_card_mult(remain_cards: int) -> int:
    for condition, mult in CARD_MULT_RULES:
        if remain_cards in condition:
            return mult
    return 1
 
CARD_MULT = { element: get_card_mult(element) for element in range(1, 14) }
 
RULES_CONFIG = {
    "max_2_cards": 4,
    "max_cards_per_player": 13,
    "power_base": 2,
    "card_mult": CARD_MULT,
}

CARD_MULT_RULES 按优先级从高到低匹配,新增规则只需追加元组,无需改动计算逻辑。

实现:计算与校验

核心计算函数与 Pandas 驱动的批处理流程:

# main.py
import pandas as pd
from config import PLAYERS_CONFIG, RULES_CONFIG, EXCEL_FILE_PATH, CARD_PRICE
 
def calculate_single_loss(remain_cards, remain_2, rules):
    if remain_cards == 0:
        return 0.0
    power_mult = rules["power_base"] ** remain_2
    card_mult = rules["card_mult"][remain_cards]
    return remain_cards * power_mult * card_mult * CARD_PRICE
 
def safe_convert_to_int(value, desc, round_num):
    try:
        return int(value), ""
    except (ValueError, TypeError):
        return None, f"第{round_num}{desc} 不是有效数字"
 
def validate_round_data(row, round_num, players, rules):
    errors = []
    player_codes = list(players.keys())
    player_data = {}
 
    col_idx = 0
    for code in player_codes:
        player_name = players[code]
        cards_val = row.iloc[col_idx]
        cards, err = safe_convert_to_int(cards_val, f"{player_name}剩牌数", round_num)
        if err:
            errors.append(err)
            col_idx += 2
            continue
 
        remain_2_val = row.iloc[col_idx + 1]
        remain_2, err2 = safe_convert_to_int(remain_2_val, f"{player_name}被抓2数", round_num)
        if err2:
            errors.append(err2)
            col_idx += 2
            continue
 
        player_data[code] = {"name": player_name, "cards": cards, "remain_2": remain_2}
        col_idx += 2
 
    if errors:
        return False, " | ".join(errors), None
 
    for code, data in player_data.items():
        if data["remain_2"] > data["cards"]:
            errors.append(f"{data['name']} 被抓2数({data['remain_2']}) > 剩牌数({data['cards']})")
 
    total_remain_2 = sum(d["remain_2"] for d in player_data.values())
    if total_remain_2 < 0 or total_remain_2 > rules["max_2_cards"]:
        errors.append(f"全局被抓2总数={total_remain_2},超出范围(0~{rules['max_2_cards']})")
 
    for code, data in player_data.items():
        if data["cards"] < 0 or data["cards"] > rules["max_cards_per_player"]:
            errors.append(f"{data['name']} 剩牌数({data['cards']}) 超出范围(0~{rules['max_cards_per_player']})")
 
    winner_count = sum(1 for d in player_data.values() if d["cards"] == 0)
    if winner_count == 0:
        errors.append("无赢家(无玩家剩牌数=0)")
    elif winner_count > 1:
        errors.append(f"赢家数量={winner_count},每局只能有 1 个赢家")
 
    if errors:
        return False, f"第{round_num}局数据异常:" + " | ".join(errors), None
    return True, "", player_data
 
def main():
    try:
        df = pd.read_excel(EXCEL_FILE_PATH, header=0)
        print(f"✅ 成功读取 Excel(玩家:{PLAYERS_CONFIG})")
        print("=" * 80)
    except FileNotFoundError:
        print(f"❌ 未找到文件:{EXCEL_FILE_PATH}")
        return
 
    total_scores = {name: 0.0 for name in PLAYERS_CONFIG.values()}
 
    for index, row in df.iterrows():
        round_num = index + 1
        is_valid, error_msg, player_data = validate_round_data(
            row, round_num, PLAYERS_CONFIG, RULES_CONFIG
        )
        if not is_valid:
            print(f"❌ {error_msg}")
            return
 
        round_losses = {}
        total_round_loss = 0.0
        winner_name = None
 
        for data in player_data.values():
            loss = calculate_single_loss(data["cards"], data["remain_2"], RULES_CONFIG)
            round_losses[data["name"]] = loss
            total_round_loss += loss
            if data["cards"] == 0:
                winner_name = data["name"]
 
        print(f"📌 第 {round_num} 局 | 赢家:{winner_name}")
        for name in PLAYERS_CONFIG.values():
            if name == winner_name:
                total_scores[name] += total_round_loss
                print(f"  {name}: 赢 {total_round_loss:.2f} 元")
            else:
                total_scores[name] -= round_losses[name]
                print(f"  {name}: 输 {round_losses[name]:.2f} 元")
        print("-" * 80)
 
    print("\n🏆 最终输赢结果")
    for name, score in total_scores.items():
        label = "赢" if score > 0 else ("输" if score < 0 else "平")
        print(f"{name}: 总共{label} {abs(score):.2f} 元")
 
if __name__ == "__main__":
    main()

运行方式:

pip install pandas openpyxl
python main.py

演进路线

阶段存储交互适用场景
V1(当前)ExcelCLI个人牌局、快速验证规则
V2SQLiteFlask / Tornado Web UI多人共用、在线查分
V3SQLite + 链上镜像Mobile(Flutter / Compose / SwiftUI)移动端录入
V4Geth 私有链Flask API + MetaMask防篡改、多方共识

Python 规则引擎可直接移植至 Dart / Kotlin / Swift;Web / Mobile 形态下,SQLite 的 CRUD 比 Excel 更适合并发读写,表结构建议拆分为 roomsroundsround_scores 三表。

区块链扩展:链上记分

动机与边界

本地 Excel / SQLite 方案对信任域内的牌局已足够。若牌友质疑记分员篡改历史数据,有两种应对:

  1. 轻量方案:计分器仅作计算器,每局 Total 由四人现场确认后再录入下一局。
  2. 链上方案:将每局原始数据写入不可篡改的账本,任何人可独立验算。

后者并非业务必需,而是探索区块链「写入即共识、历史不可改」特性在小型社交场景中的落地。

技术栈

┌────────────┐   JSON    ┌─────────────┐   eth.send   ┌──────────────────┐
│  前端 / CLI │ ────────► │  Flask API  │ ───────────► │  Geth 私有链      │
│            │ ◄──────── │  (web3.py)  │ ◄─────────── │  localhost:8545  │
└────────────┘   查询    └──────┬──────┘   事件/状态    └────────┬─────────┘
                                │                                 │
                                └──────── contract.call ──────────┘
                                          ChuDiDiScore.sol
组件作用
Solidity编写 EVM 智能合约,链上固化计分逻辑
Remix IDE在线编译、部署合约
Geth本地私有链,免 Gas 费,保留不可篡改性
MetaMask连接公链测试网时使用(需 Gas)
web3.py + Flask后端桥接,暴露 REST API

智能合约

合约 ChuDiDiScore 将 Python 侧的 _calculateLoss 逻辑移植至链上,并通过 submitRound 写入每局数据:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.16;
 
contract ChuDiDiScore {
    uint256 public constant CARD_PRICE = 1;
    uint256 public constant POWER_BASE = 2;
    uint256 public constant MAX_2_CARDS = 4;
    uint256 public constant MAX_CARDS = 13;
 
    address[4] public players;
    string[4] public playerNames;
    mapping(address => int256) public totalScores;
 
    struct Round {
        uint256 roundId;
        address winner;
        int256[4] scores;
        uint256[4] remainCards;
        uint256[4] remain2;
    }
 
    Round[] public rounds;
    uint256 public roundCount;
 
    function createRoom(address[4] calldata _players, string[4] calldata _names) public {
        for (uint256 i = 0; i < 4; i++) {
            players[i] = _players[i];
            playerNames[i] = _names[i];
        }
        roundCount = 0;
    }
 
    function _calculateLoss(uint256 remainCards, uint256 remain2) internal pure returns (uint256) {
        if (remainCards == 0) return 0;
 
        uint256 power_mult = remain2 == 0 ? 1 : (uint256(2) ** remain2);
        uint256 card_mult = 1;
 
        if (remainCards == 13) card_mult = 4;
        else if (remainCards >= 10 && remainCards <= 12) card_mult = 3;
        else if (remainCards >= 8 && remainCards <= 9) card_mult = 2;
 
        return remainCards * power_mult * card_mult * CARD_PRICE;
    }
 
    function submitRound(uint256[4] calldata _remainCards, uint256[4] calldata _remain2) public {
        require(players[0] != address(0));
 
        uint256 winnerCount = 0;
        address winner;
        uint256 totalRemain2 = 0;
 
        for (uint256 i = 0; i < 4; i++) {
            require(_remainCards[i] <= MAX_CARDS);
            require(_remain2[i] <= _remainCards[i]);
            totalRemain2 += _remain2[i];
            if (_remainCards[i] == 0) {
                winnerCount++;
                winner = players[i];
            }
        }
 
        require(winnerCount == 1);
        require(totalRemain2 <= MAX_2_CARDS);
 
        int256[4] memory roundScores;
        uint256 totalWin = 0;
 
        for (uint256 i = 0; i < 4; i++) {
            if (_remainCards[i] == 0) continue;
            uint256 loss = _calculateLoss(_remainCards[i], _remain2[i]);
            roundScores[i] = -int256(loss);
            totalWin += loss;
        }
 
        for (uint256 i = 0; i < 4; i++) {
            if (players[i] == winner) {
                roundScores[i] = int256(totalWin);
                break;
            }
        }
 
        for (uint256 i = 0; i < 4; i++) {
            totalScores[players[i]] += roundScores[i];
        }
 
        rounds.push(Round({
            roundId: roundCount + 1,
            winner: winner,
            scores: roundScores,
            remainCards: _remainCards,
            remain2: _remain2
        }));
        roundCount++;
    }
}

部署流程可参考廖雪峰的 以太坊智能合约教程,在 Remix 中编译部署后获取合约地址与 ABI。

Flask API 桥接

后端通过 web3.py 连接本地 Geth 节点(http://127.0.0.1:8545),封装链上读写:

端点方法功能
/GET健康检查
/createRoomPOST创建四人房间并上链
/submitRoundPOST提交一局 { remainCards, remain2 }
/scoresGET查询四人累计分数
/roundCountGET查询已完成局数
from flask import Flask, request, jsonify
from web3 import Web3
 
w3 = Web3(Web3.HTTPProvider('http://127.0.0.1:8545'))
 
ABI = [
    {
        "inputs": [
            {"internalType": "address[4]", "name": "_players", "type": "address[4]"},
            {"internalType": "string[4]", "name": "_names", "type": "string[4]"}
        ],
        "name": "createRoom",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {
        "inputs": [
            {"internalType": "uint256[4]", "name": "_remainCards", "type": "uint256[4]"},
            {"internalType": "uint256[4]", "name": "_remain2", "type": "uint256[4]"}
        ],
        "name": "submitRound",
        "outputs": [],
        "stateMutability": "nonpayable",
        "type": "function"
    },
    {"inputs": [], "name": "roundCount", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
    {"inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "name": "players", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
    {"inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "name": "playerNames", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"},
    {"inputs": [{"internalType": "address", "name": "", "type": "address"}], "name": "totalScores", "outputs": [{"internalType": "int256", "name": "", "type": "int256"}], "stateMutability": "view", "type": "function"},
]
 
CONTRACT_ADDRESS = "0x5FbDB2315678afecb367f032d93F642f64180aa3"
contract = w3.eth.contract(address=CONTRACT_ADDRESS, abi=ABI)
account = w3.eth.accounts[0]
 
app = Flask(__name__)
 
@app.route('/createRoom', methods=['POST'])
def create_room():
    data = request.json
    names = data.get("names", ["张三", "李四", "王五", "赵六"])
    players = w3.eth.accounts[:4]
    tx_hash = contract.functions.createRoom(players, names).transact({"from": account})
    w3.eth.wait_for_transaction_receipt(tx_hash)
    return jsonify({"code": 0, "msg": "房间创建成功", "players": players, "names": names})
 
@app.route('/submitRound', methods=['POST'])
def submit_round():
    data = request.json
    tx_hash = contract.functions.submitRound(
        data["remainCards"], data["remain2"]
    ).transact({"from": account})
    w3.eth.wait_for_transaction_receipt(tx_hash)
    return jsonify({"code": 0, "msg": "本局成绩已上链"})
 
@app.route('/scores', methods=['GET'])
def get_scores():
    result = []
    for i in range(4):
        addr = contract.functions.players(i).call()
        result.append({
            "name": contract.functions.playerNames(i).call(),
            "score": int(contract.functions.totalScores(addr).call())
        })
    return jsonify({"code": 0, "scores": result})
 
@app.route('/roundCount', methods=['GET'])
def round_count():
    return jsonify({"code": 0, "roundCount": int(contract.functions.roundCount().call())})
 
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

本地 Geth 私有链无需矿工费,适合小范围牌局建立「不可篡改」共识;公链部署则需 MetaMask 签名并支付 Gas。

小结

锄大地计分器的核心是一套可声明的规则引擎:牌数区间倍数、抓 2 指数惩罚、零和分配,Python 与 Solidity 两侧保持同一套 _calculateLoss 语义。当前 V1 以 Excel + CLI 快速落地;若需多人协作或防篡改,可按演进路线逐步引入 SQLite、Web 前端与 Geth 私有链,而不必重写计分逻辑。