锄大地计分器:规则引擎与链上记分方案
基于 Python + Excel 的锄大地(Big Two)计分系统,含规则建模、数据校验与 Solidity 链上扩展
概述
锄大地(Big Two) 是流行于广东地区的四人扑克牌游戏,规则以「先出完牌者通吃」为核心。手动记账在连局对战中容易算错倍数、遗漏抓 2 惩罚,且难以实时汇总累计输赢。
本项目实现了一套可配置的计分引擎:对局结束后录入各玩家剩余牌数与被抓 2 数量,自动计算单局输赢与累计金额。当前形态为 Python CLI + Excel 持久化;后续可扩展 Web / Mobile 前端,或通过 Solidity 智能合约实现不可篡改的链上记分。

游戏规则(计分模型)
基本约定
- 四人局,每人 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(输) | 10 | 2 | 10 × 3 × 2² × 0.5 | −60 元 |
| B(输) | 9 | 1 | 9 × 2 × 2¹ × 0.5 | −18 元 |
| C(输) | 6 | 0 | 6 × 1 × 2⁰ × 0.5 | −3 元 |
| D(赢) | 0 | 0 | 吸收全部输分 | +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 宽表结构
| Round | A Remain | A Remain 2 | B Remain | B Remain 2 | C Remain | C Remain 2 | D Remain | D Remain 2 |
|---|---|---|---|---|---|---|---|---|
| 1 | 0 | 0 | 3 | 1 | 2 | 0 | 4 | 1 |
| 2 | 3 | 0 | 7 | 2 | 9 | 1 | 0 | 0 |
每行代表一局;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(当前) | Excel | CLI | 个人牌局、快速验证规则 |
| V2 | SQLite | Flask / Tornado Web UI | 多人共用、在线查分 |
| V3 | SQLite + 链上镜像 | Mobile(Flutter / Compose / SwiftUI) | 移动端录入 |
| V4 | Geth 私有链 | Flask API + MetaMask | 防篡改、多方共识 |
Python 规则引擎可直接移植至 Dart / Kotlin / Swift;Web / Mobile 形态下,SQLite 的 CRUD 比 Excel 更适合并发读写,表结构建议拆分为 rooms、rounds、round_scores 三表。
区块链扩展:链上记分
动机与边界
本地 Excel / SQLite 方案对信任域内的牌局已足够。若牌友质疑记分员篡改历史数据,有两种应对:
- 轻量方案:计分器仅作计算器,每局 Total 由四人现场确认后再录入下一局。
- 链上方案:将每局原始数据写入不可篡改的账本,任何人可独立验算。
后者并非业务必需,而是探索区块链「写入即共识、历史不可改」特性在小型社交场景中的落地。
技术栈
┌────────────┐ 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 | 健康检查 |
/createRoom | POST | 创建四人房间并上链 |
/submitRound | POST | 提交一局 { remainCards, remain2 } |
/scores | GET | 查询四人累计分数 |
/roundCount | GET | 查询已完成局数 |
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 私有链,而不必重写计分逻辑。