首页 › 社区 › 公告与更新客户端下载

塔罗 Skill 后端实现公开(洗牌 / 切牌 / 牌义)

作者 Soulmate Agent 开发团队 · 发布于 2026-10-22 · 浏览 23

# 塔罗 Skill 后端实现公开(洗牌 / 切牌 / 牌义)

本篇公开「塔罗」技能的后端实现与核心代码。服务端只做一件事:把一副 78 张的牌洗好、切好、定好正逆位;抽牌和解牌在客户端本地完成。

一、整体链路


客户端技能(xjx-tarot-cn / -global,指导型)
        ↓
客户端核心内置工具 xjx_fetch_tarot
        ↓  (凭证由客户端核心代管)
Node 路由  POST /api/tarot/v1/shuffle
        ↓  本机调用
Python 计算服务  127.0.0.1:8098  /api/tarot/shuffle
        ↓
返回整副牌堆的顺序、正逆位、牌义

之后:模型按技能要求逐张问用户「抽第几张」,再按牌堆序号取牌、解读。抽牌动作不经过服务端,服务端也永远不知道用户抽了哪几张。

二、牌库结构

78 张固定牌序:

每张牌的数据结构:


{
    "id": 1,
    "name": "愚人",
    "arcana": "大阿尔卡纳",
    "suit": "",
    "element": "",
    "rank": "",
    "upright": "新的开始,冒险,天真,率性,充满潜力,……",
    "reversed": "鲁莽,冒险,粗心,失去控制,……",
    "key_up": ["新的开始", "冒险", "天真"],
    "key_rev": ["鲁莽", "冒险", "粗心"],
    "nature": "一切尚未定型的起点",
    "advice": "先迈步再校正,别等万事俱备",
    "advice_rev": "先看脚下,把风险边界划清再动",
    "symbols": "悬崖、白玫瑰、小狗、行囊",
}

字段分工:

| 字段 | 用途 | |---|---| | upright / reversed | 正位、逆位牌义(原文,不做改写) | | key_up / key_rev | 关键词,供解码时抓重点 | | nature | 一句定性 | | advice / advice_rev | 正位、逆位的行动指向 | | symbols | 牌面象征(大阿尔卡纳 22 张) |

三、洗牌与切牌算法

原则:用密码学安全的随机源,不用 random.random() 这类可预测序列。


import random

def shuffle_deck(body=None):
    rnd = random.SystemRandom()
    deck = list(tarot_deck.CARDS)
    rnd.shuffle(deck)

    n = len(deck)
    first_cut = rnd.randint(1, n - 1)
    part, rest = deck[:first_cut], deck[first_cut:]

    if len(part) > 1:
        second_cut = rnd.randint(1, len(part) - 1)
    else:
        second_cut = 1
    a, b = part[:second_cut], part[second_cut:]

    blocks = [a, b, rest]
    rnd.shuffle(blocks)
    merged = [c for blk in blocks for c in blk]
    merged = _riffle(merged, rnd)
    ...

具体动作对应一次真实的洗牌流程:

1. 整副洗牌:78 张随机排序; 2. 第一次切牌:从牌堆上方抓走随机张数,分成「切出的部分」与「剩下的部分」; 3. 第二次切牌:在切出的那部分里再抓一次,得到 A、B 两叠,加上剩下的 C 叠,共三叠; 4. 随机合堆:A/B/C 三叠的顺序也随机; 5. 交错合并:对合并后的牌堆做一次 riffle(交错洗牌)。

riffle 的实现里,下一张从左还是从右取,有 70% 概率换手,避免机械的严格交替:


def _riffle(cards, rnd):
    """对半分开后交错合并,交错起点随机"""
    n = len(cards)
    if n < 4:
        return cards
    mid = n // 2
    left, right = cards[:mid], cards[mid:]
    out = []
    i = j = 0
    pick_left = rnd.random() < 0.5
    while i < len(left) and j < len(right):
        if pick_left:
            out.append(left[i]); i += 1
        else:
            out.append(right[j]); j += 1
        if rnd.random() < 0.7:
            pick_left = not pick_left
    out.extend(left[i:])
    out.extend(right[j:])
    return out

四、正逆位

洗完之后逐张独立判定,正位与逆位各 50%:


for pos, c in enumerate(merged, 1):
    upright = rnd.random() < 0.5
    cards.append({
        "pos": pos,
        "id": c["id"],
        "name": c["name"],
        "arcana": c["arcana"],
        "suit": c["suit"],
        "element": c["element"],
        "rank": c["rank"],
        "orientation": ORIENT_UPRIGHT if upright else ORIENT_REVERSED,
        "meaning": c["upright"] if upright else c["reversed"],
        "keywords": c["key_up"] if upright else c["key_rev"],
        "nature": c["nature"],
        "advice": c["advice"] if upright else c["advice_rev"],
        "symbols": c.get("symbols", ""),
    })

正逆位不是「整副一起翻」,而是每张独立——这是洗牌后牌堆的真实状态。

五、为什么抽牌不放在服务端

服务端返回的是一整副已经洗好的牌堆(pos 1–78)。随后由客户端模型按技能要求:

1. 逐张问用户「抽第几张」; 2. 按用户报的序号从牌堆里取对应那张; 3. 同一张不能重复抽; 4. 未抽到的牌不提前解读,也不向用户透露牌堆顺序。

这样做的原因很直接:抽牌是用户在现场做的动作,服务端没有理由知道,也没有必要记录。服务端只保证「这副牌在洗好的那一刻是随机且完整的」。

返回体里同时给一句 text:


牌堆共 78 张,已洗牌并切牌完毕。
请让用户逐张报序号(1-78,同一张不能重复)。
未抽到的牌不要提前解读,也不要向用户透露牌堆顺序。

六、API 层与计费

七、这套实现的特点

1. 随机源是 SystemRandom:取自操作系统熵池,不是伪随机序列。 2. 洗 + 切 + 交错三段都在服务端完成:客户端拿到的是最终牌堆,不能挑牌、不能重排。 3. 正逆位逐张独立:符合真实牌堆状态。 4. 服务端不知道你抽了什么:抽牌只在本地发生,隐私边界清楚。 5. 可复现的计费:同一问题同一牌阵在窗口内重复请求,回放同一副牌,不多收钱。

参与讨论