# 塔罗 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 张固定牌序:
- 1–22:大阿尔卡纳;
- 23–36:权杖;
- 37–50:圣杯;
- 51–64:宝剑;
- 65–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 层与计费
- 入口:
POST /api/tarot/v1/shuffle(登录态); - 每成功洗一次牌(= 一次完整占卜)6 token,先算成功再扣费;
- 同一
request_id24 小时内回放,不二次扣费; - 同一问题 + 同一牌阵,120 分钟内重复洗牌会回放同一牌堆,不再扣费——避免用户刷新或重试时拿到一副新牌、多扣一次钱;
- 抽牌、解牌全部在客户端完成,服务端不产生额外请求。
七、这套实现的特点
1. 随机源是 SystemRandom:取自操作系统熵池,不是伪随机序列。 2. 洗 + 切 + 交错三段都在服务端完成:客户端拿到的是最终牌堆,不能挑牌、不能重排。 3. 正逆位逐张独立:符合真实牌堆状态。 4. 服务端不知道你抽了什么:抽牌只在本地发生,隐私边界清楚。 5. 可复现的计费:同一问题同一牌阵在窗口内重复请求,回放同一副牌,不多收钱。