Skip to content

锄大地(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表格的格式

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

这等于简单建了一个数据库表格,把所有人的剩余牌数和被抓2的属性都枚举出来了。

这在数据库表的设计上绝对不是一个好的方案(如果采用PostgreSQL或者MySQL什么的,在工作上这种比较正经的项目中,千万不要这么做,除非你想被后面维护你代码的人屌)。

但是这里是Excel,目前暂时只能这样设计。

Python脚本

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表格

python
# 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.编写合约

solidity
// 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实现的后端项目

python
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)

随便写写的,喜欢就好。