锄大地(BigTwo)
具体可以查看维基百科关于锄大地的介绍,它的历史渊源我不做过多的介绍,主要就是广东地区很喜欢的一种打牌方式,我非常喜欢玩这个游戏,尝试过一晚上最多输过四五百块。
积分规则
我们约定一局只有一个赢家,就是赢家通吃全场的状态,所有的人都以最快的方式打出所有的牌。
在一个人出完的所有牌之后,其他人就是输家,我们按照输家手中还剩下多少张牌,作为记分的基础。
比如你现在手上还剩下3张牌,通常来讲我们就是计算你为-3分。然后累计所有输家在这一局的输分,这就是赢家的分数。
剩余牌数的翻倍
如果有人赢了(出完了所有牌),作为输家手上很剩下一大堆的牌,超过一定的数字,是要罚分翻倍的。通常来讲,我玩的情况就是基数很低,比如1分是5毛钱,那么在倍数上面可以翻得大一些。我出去和别人打牌,约定的规矩,一般就是如下:
手上一张牌没出过,剩下13张,这是要翻4倍的(剩下13张,直接计算成为13 * 4 = 52分)
手上剩下的牌数在10张到12张之间(10、11、12),这是要翻3倍的(比如你现在有11张牌,那这样就要算成33分)
手上剩下的牌数在( 8 <= x < 10, 即 x = 8 or x = 9 ),那这就是要翻2倍的。(比如你现在有9张牌,那这样就要算成18分)
手上剩下的牌少于8张,那这让就不翻倍,按照正常的计算规则计算就可以了。
抓2的翻倍
如果输家手中剩下的牌含有2,这也是要翻倍的。
一张2翻2倍,两张2就翻4倍。总的来说,具体公式为:(2的n次方,n为输家被抓到的2的张数)
举个例子:
shell玩家A 输10张牌,被抓2为2张 计算: 10 * 3 * (2 * 2) = 120(输) 玩家B 输9张牌,被抓2为1张 计算: 9 * 2 * 2 = 36(输) 玩家C 输6张牌,被抓2为0张 计算: 6 * 1 * 1 = 6(输) 玩家D 赢家 计算:120 + 36 + 6 = 162(赢)那么这一局中,玩家A输120分,玩家B输36分,玩家C输6分,玩家D赢162分。最后可以结合着你们设定的一分多少钱,去具体算出这个输赢钱。比如按照我一般的输赢玩法基本都是定2毛钱一分,这一把,玩家A输24块,玩家B输7.2块,玩家C输1.2块,玩家D赢32.4块。
引入Python + Excel的解决方案
我之前玩的时候,觉得手动记账总是容易在最后的时候出错。而且这个东西用手去记,肯定是没有实时查看得到当前总共输赢那么方便。
于是我决定引入一套基于Python的记分系统,数据的存储位置放在一个Excel的表格中,每一局的剩余牌数、抓2张数,都统一在当前对局结束的时候,然后记录进去,然后计算一下每一局的输赢、总共的输赢,简化每个人的账本,只需要记住每一局之后总的输赢就好了,具体每一局的详细数据,放在这个Excel表格中存储。
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 |
这等于简单建了一个数据库表格,把所有人的剩余牌数和被抓2的属性都枚举出来了。
这在数据库表的设计上绝对不是一个好的方案(如果采用PostgreSQL或者MySQL什么的,在工作上这种比较正经的项目中,千万不要这么做,除非你想被后面维护你代码的人屌)。
但是这里是Excel,目前暂时只能这样设计。
Python脚本
# config.py
# 导入一些固定变量
# Excel文件路径
EXCEL_FILE_PATH = "./play-2d.xlsx"
# 单张牌单价(元)
CARD_PRICE = 0.5
# 玩家配置(可自定义名称,按Excel列顺序对应)
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
]
"""
根据剩余牌数获取倍数
:param remain_cards: 剩余牌数量
:return: 对应的倍数
"""
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, # 一副牌总2的数量
"max_cards_per_player": 13, # 每人初始牌数
"power_base": 2, # 被抓2的次方基数(2的n次方)
"card_mult": CARD_MULT,
}main.py方法中使用Pandas库去解析Excel表格
# 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]
total_mult = power_mult * card_mult
return remain_cards * total_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} 不是有效数字"
# ===================== 数据验证(适配动态玩家) =====================
"""
验证单局数据合法性(完全适配动态玩家)
:param row: 单局数据行
:param round_num: 局数
:param players: 玩家配置
:param rules: 规则配置
:return: (是否合法, 错误信息)
"""
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)
for code, data in player_data.items():
name = data["name"]
cards = data["cards"]
remain_2 = data["remain_2"]
if remain_2 > cards:
errors.append(f"{name} 被抓2数({remain_2}) > 剩牌数({cards}),不合理")
total_remain_2 = sum([data["remain_2"] for data 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']})")
max_cards = rules["max_cards_per_player"]
for code, data in player_data.items():
name = data["name"]
cards = data["cards"]
if cards < 0 or cards > max_cards:
errors.append(f"{name} 剩牌数({cards}),超出范围(0~{max_cards})")
winner_count = sum([1 for data in player_data.values() if data["cards"] == 0])
if winner_count == 0:
errors.append("无赢家(无玩家剩牌数=0),每局必须有1个赢家")
elif winner_count > 1:
errors.append(f"赢家数量={winner_count},每局只能有1个赢家")
if errors:
error_msg = f"第{round_num}局数据异常:" + " | ".join(errors)
return False, error_msg, 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
except Exception as e:
print(f"❌ 读取文件出错:{e}")
return
total_scores = {name: 0.0 for name in PLAYERS_CONFIG.values()}
for index, row in df.iterrows():
round_num = index + 1
required_cols = len(PLAYERS_CONFIG) * 2
if len(row) < required_cols:
print(f"❌ 第{round_num}局数据异常:列数不足(需{required_cols}列,实际{len(row)}列)")
print("⚠️ 终止计算!")
return
is_valid, error_msg, player_data = validate_round_data(
row, round_num, PLAYERS_CONFIG, RULES_CONFIG
)
if not is_valid:
print(f"❌ {error_msg}")
print("⚠️ 终止计算!")
return
round_losses = {}
total_round_loss = 0.0
winner_name = None
for _, data in player_data.items():
name = data["name"]
cards = data["cards"]
remain_2 = data["remain_2"]
loss = calculate_single_loss(cards, remain_2, RULES_CONFIG)
round_losses[name] = loss
total_round_loss += loss
if cards == 0:
winner_name = name
print(f"📌 第 {round_num} 局 | 赢家:{winner_name}")
for name in PLAYERS_CONFIG.values():
if name == winner_name:
win_money = total_round_loss
total_scores[name] += win_money
print(f" {name}: 赢 {win_money:.2f} 元")
else:
lose_money = round_losses[name]
total_scores[name] -= lose_money
print(f" {name}: 输 {lose_money:.2f} 元")
print(" --- 累计输赢 ---")
for name, score in total_scores.items():
if score > 0:
print(f" {name}: 累计赢 {score:.2f} 元")
elif score < 0:
print(f" {name}: 累计输 {abs(score):.2f} 元")
else:
print(f" {name}: 累计无输赢")
print("-" * 80)
print("\n🏆 最终输赢结果")
print("=" * 80)
for name, score in total_scores.items():
if score > 0:
print(f"{name}: 总共赢 {score:.2f} 元")
elif score < 0:
print(f"{name}: 总共输 {abs(score):.2f} 元")
else:
print(f"{name}: 总共无输赢")
if __name__ == "__main__":
main()当前方案的产品方向总结
基本原理就大致如此,这部份的代码逻辑是可以支撑运行锄大地计分器的整体运作。
当然啦,后续可以继续拓展这一块的业务范畴,当下的客户端表现形式主要是聚焦在命令行的交互行为中。如果想要拓展成为一个更加优秀的GUI形式,也是可以的。
可以引入Web的形式,透过Flask、Tornado等等后端框架,搭建一个Web应用,把UI做得精美一些,然后提供一个修改config.py中那些参数的配置界面,一个更加完整的应用产品就可以诞生了。
如果你是Flutter/Jetpack Compose/SwiftUI等等平台的Mobile选手,也可以参考上面的Python代码,迁移到Dart/Kotlin/Swift中,写一个小应用,拿出去吹吹牛忽悠一下朋友,冒充高级知识分子也是可以的。
技术上的可能变化情况
如果是迁移到Web、Mobile等等形式,Python的那一块逻辑需要以Backend Framework形式(类似Flask、Tornado等)去支撑,那么Excel表格的写入和读取就没有SQLite那么方便了。
所以这一块的存储方案,可以迁移到以SQLite去落实实现,并提供相应的CRUD操作给到Backend用API去操作数据库。
如果嫌麻烦的话,就还是以一个表格去all in所有的字段。
如果不嫌麻烦,可以自己详细去拆分所有的字段。
区块链技术的引入
为什么引入区块链
引入区块链技术的前提,其实没有应用场景,按照上面的那个本地存储方案已经足够了。
其他玩家不信任你的话,害怕你会乱改Excel表格中的数据,你就只把这个计分器当成一个计算器,每一把的对局如果其他玩家有质疑,那就手动计算每一把的Total,然后达成共识去确认。
只要你上一把得出的Total是确认的,这一把的分数也是这样的一个记分规则,然后累计起来就可以了。
所以引入区块链的目的,纯粹就是我自己个人对于区块链技术的好奇,以技术去驱动产品方案的一个事情。
区块链这个东西到底是什么呢?然后刚好切合锄大地计分器这个场景,自己构建一个情景去推进自己对于这项技术的好奇,带着问题去处理问题,而不是死记硬背一些知识点,这是一件很有趣的事情。
开发语言Solidity
区块链协议的开发需要使用到Solidity。
按照官网的说法:
Solidity 是一种面向以太坊虚拟机 (EVM) 的 带花括号的语言。 它受 C++(主要),Python(次要) 和 JavaScript(微小) 的影响。
Solidity 是静态类型语言,支持继承,库和复杂的用户自定义的类型以及其他特性。
使用 Solidity,您可以创建用于投票、众筹、秘密竞价(盲拍)以及多重签名钱包等用途的合约。
1.编写合约
// 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;
} else {
card_mult = 1;
}
return remainCards * power_mult * card_mult * 1;
}
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++) {
uint256 c = _remainCards[i];
uint256 r = _remain2[i];
require(c <= 13);
require(r <= c);
totalRemain2 += r;
if (c == 0) {
winnerCount++;
winner = players[i];
}
}
require(winnerCount == 1);
require(totalRemain2 <= 4);
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++;
}
}2.部署合约
- Remix,这是一个在线IDE,可以用于根据solidity代码生成相应的智能合约。
这一部份可以参考廖雪峰有一个博客,这里面记录了很多的细节,按照这个细节去执行相应的操作即可。
主要的点也是围绕着Remix 在线IDE怎么使用的问题。
3. 调用合约
- 如果希望使用在线的环境,准要准备MetaMusk,这是一个以太坊生态的客户端,可以参与以太坊区块链技术部署的智能合约的终端操作,包括写入和查询数据。
它存在的一个问题是,依靠线上环境,这将需要一定的手续费(因为区块链技术需要依靠矿工算力去生成相应的链),如果只希望应用区块链技术中的不可变更性(只需要一个不可变更性的存储仓库,然后小范围可以建立起来一个共识),那么直接在本地环境通过Docker部署一个Geth即可。这是一个基于Go实现的以太坊区块链节点。
4. 数据使用
Remix 会提供相应的ABI,部署完成Geth之后,会得到一个8545端口的Geth私有链,比如http://localhost:8545。这个Geth私有链,你可以理解为一个数据不可被修改、删除的数据库。在这个背景下,你可以接入Flask或者其他的后端框架,实现你的数据插入、数据查询逻辑。
这里提供一个使用Python Flask实现的后端项目
from flask import Flask, request, jsonify
from web3 import Web3
# 连接本地 Geth 私有链
w3 = Web3(Web3.HTTPProvider('http://127.0.0.1:8545'))
# 你们的合约 ABI(你提供的)
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": "CARD_PRICE", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "MAX_2_CARDS", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "MAX_CARDS", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
{"inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "name": "playerNames", "outputs": [{"internalType": "string", "name": "", "type": "string"}], "stateMutability": "view", "type": "function"},
{"inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "name": "players", "outputs": [{"internalType": "address", "name": "", "type": "address"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "POWER_BASE", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
{"inputs": [], "name": "roundCount", "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "stateMutability": "view", "type": "function"},
{"inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], "name": "rounds", "outputs": [{"internalType": "uint256", "name": "roundId", "type": "uint256"},{"internalType": "address", "name": "winner", "type": "address"}], "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]
# ==========================================
# Flask API 服务
# ==========================================
app = Flask(__name__)
# --------------------
# 1. 创建房间(4人)
# --------------------
@app.route('/createRoom', methods=['POST'])
def create_room():
try:
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
})
except Exception as e:
return jsonify({"code": -1, "error": str(e)})
# --------------------
# 2. 提交一局成绩
# --------------------
@app.route('/submitRound', methods=['POST'])
def submit_round():
try:
data = request.json
remainCards = data["remainCards"]
remain2 = data["remain2"]
tx_hash = contract.functions.submitRound(remainCards, remain2).transact({
"from": account
})
w3.eth.wait_for_transaction_receipt(tx_hash)
return jsonify({
"code": 0,
"msg": "✅ 本局成绩已上链,不可篡改!"
})
except Exception as e:
return jsonify({"code": -1, "error": str(e)})
# --------------------
# 3. 查询所有玩家总分
# --------------------
@app.route('/scores', methods=['GET'])
def get_scores():
try:
result = []
for i in range(4):
addr = contract.functions.players(i).call()
name = contract.functions.playerNames(i).call()
score = contract.functions.totalScores(addr).call()
result.append({
"index": i,
"address": addr,
"name": name,
"score": int(score)
})
return jsonify({
"code": 0,
"scores": result
})
except Exception as e:
return jsonify({"code": -1, "error": str(e)})
# --------------------
# 4. 查询总局数
# --------------------
@app.route('/roundCount', methods=['GET'])
def round_count():
cnt = contract.functions.roundCount().call()
return jsonify({
"code": 0,
"roundCount": int(cnt)
})
# --------------------
# 健康检查
# --------------------
@app.route('/', methods=['GET'])
def index():
return jsonify({
"name": "锄大地 区块链记分 API",
"status": "running",
"chain": "Geth private"
})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)