mcd-gacha-collector · git:20261023.3d9c60f · 2026-10-23 · sha256 e84ef9fda698f525

mcd-gacha-collector git:20261023.3d9c60fA

Immutable. This exact content never changes and is served at /api/v1/blob/e84ef9fda698f525.

---
name: mcd-gacha-collector
description: 麦卡收藏家。把麦当劳餐品变成可收集的卡牌:引导用户接入麦当劳 MCP、输入地址定位最近门店、确认抽卡范围与次数,然后拉取该门店实时菜单价格与全量营养数据,构建按收藏价值分级的 SSR/UR/R 卡池(含金卡/银碎/闪卡三种卡面特效与独立闪卡变体),完成确定性加权抽卡并渲染卡面。适用于"抽张麦当劳卡""50元内抽卡""看看我能抽到什么""集齐麦当劳餐品""我的收藏册""卡上热量多少""抽到的卡能直接买"等诉求,以及用户询问附近麦当劳在哪、麦当劳怎么接入等入门问题。
agent_created: true
---

# 麦卡收藏家 · mcd-gacha-collector

> 🎯 **给使用者的第一句话**:
> 「🍟 欢迎玩麦卡收藏家!抽卡需要先连接麦当劳官方数据源,跟我走三步👇」
>
> 本 Skill 面向**第一次使用的人**,你的首要职责是**把对方一步步带进状态**,
> 而不是直接甩结果。三阶段引导见下方「工作流(引导式·三阶段)」。

## 这个 Skill 解决什么问题

麦当劳有 160+ 餐品,但每个餐品的热量、蛋白质、钠含量是多少,很多人说不清。
本 Skill 把餐品变成**可收集的卡牌**:每张卡标注价格与营养数据,按收藏价值划分稀有度,
抽到的卡可以直接转成真实订单。

它是个**纯游戏化玩法**,不是营养建议工具,也不是官方积分体系。

本 Skill 的分工非常明确:

- **MCP 负责「取数」**:餐品营养数据、门店实时菜单与价格。
- **`scripts/gacha.py` 负责「算」**:健康度评分、稀有度划分、加权抽卡、积分账本。
- **你(Agent)负责「说」和「做」**:引导接入、定位门店、把卡讲成人话、记收藏、经确认后下单。

> 🚫 **对话里只输出文字,不生成 HTML**。
> 用户想看卡面视觉(含金卡/银碎/闪卡特效),请引导他打开在线版网页
> `https://qingka-mp.app.workbuddy.host/`。

> **网页/Agent 双端同步**:网页端构建产物 `tools/data.json` 是双方共享的卡池源
> (含 66 张卡:53 张原卡 + 13 张独立闪卡变体)。`scripts/gacha.py` 会自动读取该文件,
> **保证两端抽到的卡牌口径完全一致**。

> 核心纪律:**永远不要自己口算健康度、稀有度或抽卡结果**。
> 一切以 `gacha.py` 的输出为准。

## 何时使用

**🎴 抽卡类**(主场景):
"抽张麦当劳卡" · "50 元内抽一张" · "来十连" · "我想集齐麦当劳所有餐品" ·
"看看我能抽到什么级别的卡" · "抽到的卡能直接买吗"

**📖 查询类**:
"我的收藏册有哪些卡" · "卡上热量多少" · "附近有麦当劳吗" ·
"我想看菜单价格"

**🆘 入门类**(需要走【阶段 A/B】引导):
"怎么玩" · "怎么接入" · "麦当劳抽卡怎么用" · "第一次来" ·
"我要抽卡"(但可能没配 MCP)· "附近哪家麦当劳"

**⚠️ 判断要点**:只要用户表达了抽卡意图但**尚未配好 MCP 或未定门店**,
就先走引导流程,不要直接尝试抽卡。

## 稀有度规则(综合收藏价值,与健康度完全解耦)

**核心设计原则**:稀有度回答「值不值得收集」,健康度回答「好不好」。
两者是**正交维度**——若稀有度由健康度决定,会出现
「高热量低营养 = R 档」这类误导,玩家会误以为 R 档就是差。

### 综合收藏价值分(0~1)

```
价值分 = 价格 × 50% + 热量载荷 × 30% + 蛋白含量 × 20%

  价格    = min(价格 / 62.5, 1.0)     # 62.5 元为卡池最高价
  热量载荷 = min(热量 / 1000, 1.0)     # 1000 kcal 为正餐基准
  蛋白含量 = min(蛋白 / 40, 1.0)      # 40 g 为卡池最高值
```

三个维度都是**「越高越稀有」**,但衡量的都是**收集价值**,不是营养质量。

### 分档(按预算层**分层抽样**)

关键:**不是全池统一排名**,而是**每个预算层内部独立切三档**。

预算层划分(对应网页的预算选项):

| 预算层 | 区间 |
|---|---|
| 层 0 | ≤ ¥15 |
| 层 1 | ¥15 ~ ¥30 |
| 层 2 | ¥30 ~ ¥50 |
| 层 3 | > ¥50(不限) |

每层内按价值分降序:前 25% = SSR,接着 33% = UR,后 42% = R。
层内卡数 < 4 张时不再细分(避免「1 张卡塞 3 档」)。

| 稀有度 | 层内占比 | 含义 |
|---|---|---|
| **SSR** | 前 25% | 该预算档里最"重"的选择 |
| **UR** | 25% ~ 58% | 中等 |
| **R** | 后 42% | 该档里最轻的 |

抽卡权重 SSR 5% / UR 20% / R 75%。

**为什么必须分层**:全池排名会让「15 元以内」永远抽不到 SSR
(该层价值分天然偏低:实测 ≤15 元层价值仅 0.07~0.22,全池 SSR 阈值 0.74)。
玩家选了 15 元预算却一张 SSR 都见不到,**玩法体验直接断裂**。
分层后每个预算档都有完整三档:

| 预算层 | 卡数 | SSR | UR | R |
|---|---:|---:|---:|---:|
| ≤15 元 | 15 | 4 | 5 | 6 |
| 15~30 元 | 18 | 5 | 6 | 7 |
| 30~50 元 | 8 | 3 | 2 | 3 |
| >50 元 | 12 | 4 | 3 | 5 |

典型:¥14.5 的中薯条是 SSR(金卡),¥62.5 的培根安格斯厚牛堡也是 SSR——
**同一稀有度可以横跨所有价位**,玩家按预算抽都有好卡。

### 卡面特效(金卡 / 银碎 / 闪卡)

三种特效**覆盖整张卡面**(含数据区),**不加任何描边**,全部用粒子 + 光效实现。

| 特效 | 视觉 | 实现要点 |
|---|---|---|
| **金卡 gold** | 满卡金粉颗粒(16 颗,4-6px)+ 顶部/底部/侧向鎏金光晕 + 旋转金色光轮 | `radial-gradient` 粒子 + `conic-gradient` 光轮 + `mask` 挖空 |
| **银碎 silver** | 满卡银色箔片(14 颗)+ 冷银氛围光 + 冷光轮 | 同结构,冷色系 |
| **闪卡 flash** | 双道斜向彩虹流光交错扫过 + 彩虹光轮 + 彩虹氛围光 | 移动 `linear-gradient` × 2 + `mix-blend-mode:screen` |

**闪卡是独立卡项**,不是附加属性:卡池里 SSR/UR 中约 45% 会**额外产出一张闪卡版**
(`id` 加 `_flash` 后缀,`is_variant=true`),与原卡并存、可分别收集。

- 卡池从 53 张扩到 **66 张**(+13 张闪卡)
- 卡名旁有「闪卡版」标签(彩虹渐变底)区分
- 变体卡复用原卡的图源,但卡面路径独立(便于用户自制)

出现概率(按稀有度递进):

| 稀有度 | 金卡 | 银碎 | 闪卡 | 无特效 |
|---|---:|---:|---:|---:|
| SSR | 34% | 30% | 18% | 18% |
| UR | 5% | 22% | 16% | 57% |
| R | 0% | 3% | 10% | 87% |

**确定性分配**:用卡片 id 的 MD5 哈希映射到 [0,1),保证同一张卡
每次载入特效一致,不会闪烁变化。

**无障碍**:`prefers-reduced-motion` 下所有特效动画停止,保留静态效果。

### 解耦验证(parity_check 强制断言)

| 稀有度 | 价值分 | 健康度区间 |
|---|---|---|
| SSR | 0.74 ~ 0.99 | 57.0 ~ 81.9 |
| UR | 0.27 ~ 0.72 | 56.6 ~ 79.7 |
| R | 0.07 ~ 0.27 | 64.8 ~ **85.3** |

- 全卡池**最低健康度 56.6 是 UR**,**最高健康度 85.3 是 R**(大杯鲜萃咖啡)
- 最高价值 0.990 的培根安格斯厚牛堡三件套,健康度只有 57.0
- 两维度完全正交,parity_check 同时断言「价值分随稀有度单调」+「健康度区间重叠」

---

## 健康度评分(依据《体重管理指导原则(2024 年版)》)

健康度评分 0~100,**依据国家卫生健康委《体重管理指导原则(2024 年版)》的摄入量标准**
由三项加权得出(**仅用于游戏化分级,不是营养建议**):

| 维度 | 权重 | 依据原则 | 说明 |
|---|---|---|---|
| 单餐能量占比 | 40% | 「每日减少 500~1000 千卡」「三餐早30%/午40%/晚30%」 | ≤640 kcal 满分(减重期上限),≥1000 kcal 零分 |
| 蛋白质密度 | 30% | 「蛋白质供能 15~20%,减重期 20~30%」 | 每 100 大卡含多少克蛋白,≥15g 满分 |
| 低钠 | 30% | 「减少高糖、高油、高盐食物」 | 钠 0 最优,2000mg 以上 0 分 |

### 参考常量

| 常量 | 值 | 出处 |
|---|---|---|
| 日均参考摄入 | 2000 kcal | 成年轻体力活动者 |
| 一餐基准 | 800 kcal | 2000 × 午餐占比 40% |
| 减重期单餐上限 | 640 kcal | 基准的 80%(配合每日 500~1000 kcal 缺口) |
| 高能量食物红线 | 1000 kcal | 占日均 50% |

### 套餐用独立算法

套餐本身就是**一整顿**,需按整餐视角评估(否则集体低分):

| 维度 | 权重 |
|---|---|
| 蛋白质密度 | 35% |
| 钠密度 | 30% |
| 整餐能量占比(1000 kcal 满分,1200 kcal 零分) | 35% |

套餐与单品**分别计算分位阈值**,两者的评分口径不同
(套餐满分基准 1000 kcal,单品 640 kcal)。

> ⚠️ 注意:**健康度不再决定稀有度**。评分只用于展示与排序,
> 稀有度请见上文「稀有度规则(与健康度完全解耦)」。

### 卡面标签体系(原始克数不展示)

卡面只显示**四个通俗等级标签**,不显示蛋白质/脂肪/钠的原始克数——
降低认知负担,原始数据仅在点击详情时呈现。

| 标签 | 判定 | 依据原则 |
|---|---|---|
| **能量** | ≤640 kcal 轻 / ≤800 kcal 中 / 其余重 | 一餐基准 800 kcal(午餐占 40%),减重期上限 640 |
| **蛋白** | 供能比 ≥20% 高 / ≥12% 中 / 其余低 | 蛋白质供能 15~20%,减重期 20~30% |
| **脂肪** | 供能比 ≤30% 低 / ≤42% 中 / 其余高 | 脂肪供能 20~30% |
| **钠** | 钠密度 ≤120 mg/100kcal 低 / ≤220 中 / 其余高 | 「减少高盐食物」,每日 <2000mg |

实测三档分布(53 张卡):能量 32/6/14、蛋白 14/19/12、脂肪 18/19/7、钠 20/20/7——
三档都有卡,不会出现「全是高」或「全是低」。

**阈值依据实测分布设定**,而非直接照搬原则区间:照搬会导致绝大多数餐品落在一档,
标签失去区分度。原则给的是方向,实测分布决定分界点。

### 新算法带来的分级变化

引入「单餐能量占比」后,超出一餐基准的餐品明显降档:

| 餐品 | 热量 | 占日摄入 | 旧档位 | 新档位 |
|---|---:|---:|:---:|:---:|
| 大杯鲜萃咖啡 | 13 kcal | 0.7% | SSR | **SSR** |
| 麦麦脆汁鸡-琵琶腿 | 173 kcal | 8.6% | UR | **SSR** ↑ |
| 巨无霸三件套 | 949 kcal | 47.4% | SSR | **UR** ↓ |
| 培根蔬萃双层牛堡三件套 | 878 kcal | 43.9% | UR | **SSR** ↑ |

## 工作流(引导式·三阶段)

> ⚠️ **本 Skill 是分享给他人使用的**,对方机器上没有 MCP、没有门店、没建过卡池。
> 你的首要职责是**把对方一步步带进状态**,而不是直接甩结果。
> 全流程分三阶段,**必须按顺序走**,不要跳步:

```
┌─────────────────────────────────────────────────────┐
│  阶段 A · 接入检查        对方有没有麦当劳 MCP?      │
└─────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────┐
│  阶段 B · 门店定位        让对方说地址,找最近的店     │
└─────────────────────────────────────────────────────┘
                        ↓
┌─────────────────────────────────────────────────────┐
│  阶段 C · 抽卡确认        预算范围 + 抽几张 + 开抽    │
└─────────────────────────────────────────────────────┘
```

---

### 🅰️ 阶段 A · 接入检查(每次会话开始先做)

**先判断 MCP 是否可用**:检查当前会话有没有 `mcp__mcd-mcp__*` 系列工具。

#### A-1 · MCP 未接入 → 引导对方去申请

用下面的格式回复(emoji + 字符排列,让对方一眼看懂步骤):

```
🍟 **欢迎来到麦卡收藏家!**

抽卡需要连接麦当劳官方数据源(MCP),三步搞定👇

━━━━━━━━━━━━━━━━━━━━━━
① 申请 Token
   打开 👉 https://open.mcd.cn/mcp
   用手机号登录 → 找到「控制台」
   点「激活」→ 复制那串 Token(类似 ghp_xxxxx)

② 配置连接
   在这里点【连接器】→ 右上角【自定义连接器】
   → 【配置 MCP】
   把 mcp-config.example.json 的内容粘进去
   把 ${MCD_MCP_TOKEN} 换成你刚复制的 Token
   保存 → 点【启用】

③ 回来找我
   说一句「好了」或「已配置」
   我马上带你抽卡🎴
━━━━━━━━━━━━━━━━━━━━━━

💡 Token 只存在你本机,不会上传到任何地方。
```

**关键**:如果用户在 WorkBuddy 里已经装过麦当劳 connector,
告诉他**不用重复申请**——直接说「你已经有 connector 了,让我先试试能不能取到数据」,
然后实际调一次 `mcp__mcd-mcp__now-time-info` 验证。**能调通就直接进入阶段 B。**

#### A-2 · MCP 已接入 → 验证连通

直接调一次 `mcp__mcd-mcp__now-time-info` 探活。

- 成功 → 一句话告知:`✅ MCP 已连上!现在告诉我你在哪个位置,我们找附近的店。`
- 失败(报 401 / token 无效)→ 引导回 A-1 第①步,重点提示「Token 可能过期了,重新激活一下」

---

### 🅱️ 阶段 B · 门店定位(拿真实价格的关键)

**为什么必须做**:卡池价格来自采样门店(沈阳站 ¥28 的巨无霸,
其他城市可能 ¥25 也可能 ¥32)。**不定位门店 = 价格不准 = 下单会失败。**

#### B-1 · 问地址(用友好格式,别像填表格)

```
📍 **你在哪个位置?**

我需要找到离你最近的麦当劳,才能给你准确的价格。

回我一句话就行,比如:
  · 「北京市朝阳区朝阳站」
  · 「上海 静安寺」
  · 「杭州西湖区文三路」

顺便说一下取餐方式👇
  🍱 到店自取(推荐)
  🚗 得来速车道(点了直接开车过去拿)
```

**不要**让用户填表单、**不要**一次问 5 个问题。
先问地址,再问取餐方式——或者用户一句话里都说了就直接用。

#### B-2 · 调接口找店

到店自取用 `query-nearby-stores`(`searchType=2`,传 `city` / `keyword` / `beType=1`)。

> ⚠️ **得来速(beType=5)有坑**:外送场景下必须传 beCode,
> 直接调用会报「外送场景下必传 beCode」。建议先用 `beType=1` 定位,
> 拿到门店后再按需切换。

#### B-3 · 把门店列表给用户挑(必须按距离排序)

回复格式(**距离 + 门店名 + 地址**,用 🚗 标出远近):

```
🏪 **附近有 3 家麦当劳:**

┌────────────────────────────────────────┐
│ 🚗 49 米   麦当劳沈阳和平湾悦融路得来速餐厅   │
│            悦融路                        │
│ 🚗 320 米  麦当劳太原街得来速餐厅          │
│            太原街 128 号                 │
│ 🚗 1.2 公里 麦当劳中街广场餐厅             │
│            中街路 23 号                  │
└────────────────────────────────────────┘

回复数字选一家,或者直接说店名👆
```

**铁律**:
1. **必须带 `distance`**(到店距离)并**按距离升序**排列——用户默认想去最近的
2. 不要承诺「自动定位」——浏览器 GPS 无法换算门店,一律走关键词搜索
3. MCP 返回的经纬度实测恒为 `0.0`,**不能用坐标算距离**
4. 用户选定后,把 `storeCode` / `beCode` 存进上下文,后续所有调用都用它

---

### 🅲 阶段 C · 抽卡确认(信息齐了才开抽)

门店确定后,**一次性**把剩余参数问清,不要挤牙膏:

```
🎴 **最后确认两件事就能开抽👇**

┌─ ① 抽卡范围 ─────────────────────────┐
│ 💰 不限        → 全菜单都能抽         │
│ 💰 ≤30 元      → 平价档,R 卡为主     │
│ 💰 ≤50 元      → 中价档,SSR 会出现    │
│ 💰 ≤100 元     → 全价套餐也能抽        │
└──────────────────────────────────────┘

┌─ ② 抽几张 ───────────────────────────┐
│ 🎴 单抽 ×1      1积分,适合先试试手气   │
│ 🎴 五连 ×5      5积分,中等刺激         │
│ 🎴 十连 ×10     10积分,一波带走        │
└──────────────────────────────────────┘

直接回复就行,比如「50元内 十连」or「不限 单抽」
```

#### C-1 · 用户已明确说了参数 → 直接开抽

如果用户第一句就说清了("50 元内抽十连"),**直接开抽**,只回显确认:

```
🎯 收到:**50 元内 · 十连 ×10**

正在为你构建卡池…
```

#### C-2 · ⚠️ 只说「抽卡」没说参数 → **必须问,不要自己定**

这是最容易犯的错。用户只说「抽卡」「来一张」「抽」时,
**不要**默认不限预算、默认抽 1 次然后直接开抽——
**抽卡数与预算范围是用户的决策,不是你的**。

用 `AskUserQuestion` 或上面那套格式问清楚,**等用户回复后再抽**:

```
🎴 **开抽前确认两件事👇**

┌─ ① 抽卡范围 ─────────────────────────┐
│ 💰 不限        → 全菜单都能抽         │
│ 💰 ≤30 元      → 平价档,R 卡为主     │
│ 💰 ≤50 元      → 中价档,SSR 会出现    │
│ 💰 ≤100 元     → 全价套餐也能抽        │
└──────────────────────────────────────┘

┌─ ② 抽几张 ───────────────────────────┐
│ 🎴 单抽 ×1      1积分,适合先试试手气   │
│ 🎴 五连 ×5      5积分,中等刺激         │
│ 🎴 十连 ×10     10积分,一波带走        │
└──────────────────────────────────────┘

回复格式举例:「≤50 元 · 十连」or「不限 · 单抽」
```

**判断规则**:

| 用户说了什么 | 你该做什么 |
|---|---|
| 「抽卡」「抽一张」「抽」 | ❌ **不要自己定参数**,问预算 + 次数 |
| 「抽一张」但没说预算 | 只问预算(次数已明确 = 1) |
| 「50元内抽」 | 只问次数(预算已明确) |
| 「50元内十连」 | ✅ 参数齐了,直接开抽 |
| 「随便」「都行」 | 可以问一句「那我按 ≤50 元单抽来?」,**得到确认再抽** |

**例外**:如果用户是**老玩家**(`ledger.json` 已有 ≥10 次抽卡记录),
可以沿用上次的参数,但**必须在回复里明说**:

```
🎯 上次你抽的是「≤50 元 · 十连」,这次沿用?
   换个范围的话说一声就行👌
```

**铁律**:**没有用户明确表达的预算与次数,就不要调用 `gacha.py --draw`。**
宁可多问一句,也不要替用户做决定。

#### C-3 · 积分不足时

如实说,别含糊:

```
⏳ **积分不够了**

这次需要 10 积分,当前只有 5 分。

📈 积分每 4 小时恢复 1 次,19:30 恢复
   或者点右上角「刷新积分」立即补满到 30 分

要现在补满继续抽吗?
```

---

### 📥 阶段 C 之后 · 取数与抽卡

#### 第 1 步:通过 MCP 取数

按顺序调用:

1. `now-time-info` —— 拿当前时间,判断餐品供应时段
2. `query-meals`(传已确定的 storeCode + beType)—— **拉取该门店实时菜单与价格**
3. `list-nutrition-foods` —— **拉取全量餐品营养数据**(约 160 项)

> `list-nutrition-foods` 是全平台全量,`query-meals` 是当前门店实际在售。
> **卡池只保留两者交集**——门店卖不到的餐品不进卡池,否则用户抽到买不到。

#### 第 2 步:归一化并构建卡池

把 MCP 结果写成两个临时文件:

```
tmp/nutrition.json   # {"items": [{productName, energyKcal, protein, fat,
                #                carbohydrate, sodium, calcium, energyKj}, ...]}
tmp/prices.json      # {"prices": {"巨无霸": 28.0, ...}}
```

字段映射详见 [`references/mcp-tools.md`](references/mcp-tools.md)。

```bash
python scripts/gacha.py tmp --inspect --budget 50
```

输出告诉你卡池总数、三档分布、预算内有多少张可抽。

#### 第 3 步:抽卡

```bash
python scripts/gacha.py tmp --draw 10 --budget 50 --ledger tmp/ledger.json
```

值得讲给用户的字段:

- `drawn[]` —— 抽到的卡(卡名、稀有度、价格、热量、健康度)
- `recover` —— 本次恢复多少积分、距下次恢复几小时
- `stats` —— 收藏册总览(已收集数、三档分布、平均健康度)
- `next_recover_at` —— 下次恢复时间(积分不足时告诉用户)

**积分不足**(`success: false`):如实告知还差多少、下次恢复时间,不要编造。

#### 第 4 步:只用文字回复 + 问用户买哪个

> 🚫 **绝对不要**调用 `render.py`、**绝对不要** `present_files` 打开任何 HTML。
> 本 Skill 在对话里**只输出文字**。`scripts/render.py` 是给开发者离线导出用的,
> 它的视觉与网页端**不一致**(没有卡面特效、没有分层稀有度色带),
> 给用户看会显得像半成品。用户想看视觉,可以自己打开在线版网页。

**回复模板**(多抽时逐张列,单抽时只列一张):

```
🎴 **抽到 3 张:**

💎 **UR** 不素之霸双层牛堡三件套
💰 ¥27.50 · 🔥 493 kcal(占日 25%)
💎 收藏价值 0.62 · 💚 健康度 78 · 🥈 银碎特效

🎴 **R** 脆薯饼
💰 ¥8.00 · 🔥 146 kcal(占日 7%)
💎 收藏价值 0.18 · 💚 健康度 72

🎴 **R** 鲜萃咖啡
💰 ¥10.00 · 🔥 13 kcal(占日 1%)
💎 收藏价值 0.09 · 💚 健康度 85

━━━━━━━━━━━━━━━━━━━━━
📦 你的选择:**要买哪一张?**

回我卡名就行,比如「买不素之霸三件套」
也可以说「都不买」或「再来五张」

💡 想看卡面视觉效果,可以打开在线版:
   https://qingka-mp.app.workbuddy.host/
```

**关键要求**:

1. **必须以「要买哪一张?」收尾** —— 抽卡的目的就是下单,不问就等于没闭环
2. **列出关键字段即可**:卡名、稀有度、价格、热量、健康度、特效。
   **不要**罗列蛋白质/脂肪/钠的原始克数,也不要罗列收藏册统计
3. **用户想看视觉效果**时,只给在线版链接,**不要**现场生成 HTML
4. 高稀有度用 ✨✨ / 💎 加粗,低稀有度用 🎴 平淡处理——让用户一眼看出哪张值得买
5. 抽到 SSR / 金卡时可以加一句恭喜,但**不要过度渲染**(用户关心的是买哪个)

**禁止事项**(写死,不要临场发挥):

|❌ 不要做 | ✅ 应该做 |
|---|---|
| `python scripts/render.py ...` | 不调用|
| `present_files(["cards.html"])` | 不调用 |
| `present_files(["docs/index.html"])` | 给文字链接让用户自己开 |
| 生成 `skill-card.html` / `cards.html` / 任何临时 HTML | 只输出 Markdown 文字 |
| 罗列全部营养字段 | 只讲 4~5 个关键字段 |
| 抽完就结束 | **必须问「买哪一张」** |

---

### 🛒 真实下单(强制二次确认,不可跳过)

用户点击「立即购买」或说「买这张 / 就这个」时,**严格按顺序**执行:

```mermaid
sequenceDiagram
    participant U as 用户
    participant A as Agent
    participant M as 麦当劳 MCP

    U->>A: 买这张
    A->>A: 确认已选门店(没有就先第 5 步定位)
    A->>M: query-meals(storeCode, beType)
    M-->>A: 门店实时在售 + 价格
    A->>M: calculate-price(storeCode, items)
    M-->>A: 核价结果(含优惠/配送费)
    A-->>U: 复述餐品+门店+总价 ⏸ 等待确认
    U->>A: 确认下单
    A->>M: create-order(...)
    M-->>A: orderId + payH5Url
    A-->>U: 订单号 + 支付链接
```

**铁律**:

1. **必须先调 `calculate-price` 核价**,并把最终总价(含优惠、配送费)复述给用户。
2. **必须等用户明确说「确认/下单/买」才可调 `create-order`**。
   用户只说「看看」「多少钱」时**绝不下单**。
3. 用户说「换一家」「改价格」→ 回到第 5 步重新定位,不要在旧门店硬下。
4. 下单成功后把 `payH5Url` 完整给出,并提示**订单有效期**(超时可能自动取消)。
5. 用户要取消 → `cancel-order`(需 `orderId`,并复述取消原因后再执行)。

> `create-order` 会**真实创建订单并占用库存/产生待支付款项**,
> 是不可逆的对外动作。未经用户明确同意绝不执行。
> 网页端同理:后端用一次性 `quoteToken` 强制「先核价、后确认、再下单」,
> 伪造 token 会被直接拒绝。

## 卡面图片约定

**默认来源:MCP 真实菜单图。** `query-meals` 返回的 `meals[].image` 是麦当劳官方菜单图
(CDN: `menu-img.mcd.cn`),已自动注入 `Card.image_url`,渲染时优先使用。

回退路径:当卡片缺少 `image_url`(如自定义卡池)时,使用 `Card.image_path` 字段,
约定如下:

```
assets/cards/{rarity}_{餐品名}.png
# 例:assets/cards/ssr_浓缩咖啡.png
#     assets/cards/ur_纯牛奶(盒装).png
#     assets/cards/r_巨无霸.png
```

缺失图片时页面会自动显示占位图标(按品类 emoji),不会报错。
若要批量生成卡池中所有卡的占位清单:

```bash
python scripts/gacha.py tmp --inspect
# 从输出的 image_path 字段取全部路径
```

## 硬性约束

- **积分机制是本地游戏化的**,与麦当劳官方积分账户(`query-my-account`)相互独立,
  不消耗也不影响用户真实积分。**不要向用户暗示这是麦当劳官方积分。**
- 健康度评分与稀有度仅供趣味参考,**不构成医疗、营养或健康建议**。
- 价格与营养数据一律以 **MCP 实时返回**为准;本地示例数据只是结构示例。
- 绝不编造不存在的餐品、价格或营养数据。MCP 没返回的餐品不能进卡池。
- 卡池只用「门店在售 ∩ 平台有营养数据」的交集,避免抽到买不到的卡。
- 描述卡牌时只陈述客观数据(价格、热量、健康度评分),**不对麦当劳产品做主观评价**,
  不使用"好吃""垃圾"等口味化表述,也不与其他品牌做对比。

---

## 🧭 首次使用自检清单

回复用户**之前**先过一遍这张表,确保没有遗漏前置条件:

| # | 检查项 | 怎么确认 | 没做过就… |
|---|---|---|---|
| 1 | MCP 已接入? | 调 `mcp__mcd-mcp__now-time-info` | 走【阶段 A】给申请指引 |
| 2 | Token 有效? | 上一步返回成功即有效 | 报 401 → 提示重新激活 |
| 3 | 知道用户位置? | 对话里出现过城市/地标 | 走【阶段 B】问地址 |
| 4 | 确定了门店? | 上下文里有 `storeCode` | 调 `query-nearby-stores` 列店让用户挑 |
| 5 | **用户明确说了预算?** | 用户原话里有金额或"不限" | **问,别自己定** 🚫 |
| 6 | **用户明确说了次数?** | 用户原话里有数字(单抽/五连/十连) | **问,别自己定** 🚫 |
| 7 | 积分够吗? | 读 `ledger.json` 的 `points` | 不够 → 告知恢复时间或刷新建议 |

**前 7 项全绿才开抽。** 第 5、6 项是**用户决策**,替用户做主是本 Skill 最大的忌讳。

### 🚫 两条红线

**红线 1:没确认参数不许抽**
用户只说「抽卡」时,**不要**默认不限预算 + 单抽然后直接开抽。
先用【C-2】的格式问清楚,**等回复再抽**。

**红线 2:对话里不生成 HTML**
抽完只用 **Markdown 文字**回复 + 问「要买哪一张?」。
**不要**调 `render.py`,**不要** `present_files` 打开任何 HTML——
那个渲染器是旧版视觉(无特效、无分层色带),给用户看像半成品。
用户想看视觉,引导他打开在线版:<https://qingka-mp.app.workbuddy.host/>

### 用户情绪观察

| 用户状态 | 你的应对 |
|---|---|
| 第一次来,说「怎么玩」 | 从阶段 A 开始,完整带一遍 |
| 直接说「抽卡」但没配 MCP | 先走 A,**不要**假装能抽 |
| **只说「抽卡」没说参数** | **走 C-2 问预算 + 次数,别自己定** 🚫 |
| 已经抽过,问「再来十连」 | 跳过 A/B,只确认 C 的预算与次数 |
| 说「换个城市」 | 回到阶段 B,重新定位门店 |
| 抱怨「抽到的卡不好」 | 说明稀有度按收藏价值分(价格/热量/蛋白),与健康度无关,是正交维度 |

---

## 📎 回复格式约定

给使用者的回复**统一用 emoji + 字符框**,让信息层次清楚:

| 用途 | 元素 |
|---|---|
| 阶段标题 | 🅰️ 🅱️ 🅲 或 ✅ ⏳ 🎴 |
| 分隔线 | `━━━` 或 `┌─ ─┐` 框线 |
| 门店距离 | 🚗(49 米 / 1.2 公里) |
| 预算 | 💰 |
| 抽卡次数 | 🎴 |
| 抽到高稀有度 | ✨✨ SSR / 💎 UR |
| 卡片字段 | 🍔 卡名 · 💰 价格 · 🔥 热量 · 💎 收藏价值 · 💚 健康度 |
| 状态提示 | ✅ 成功 · ⏳ 等待 · ⚠️ 注意 · ❌ 失败 |

**关键原则**:
- 步骤超过 2 个时用框线分隔,别写成一大段
- 每个选项前面加 emoji,让用户扫一眼就能选
- 不要让用户填表单——用自然语言一句话就能说完的事,别拆成多个问题

---

## 资源索引

- [`scripts/gacha.py`](scripts/gacha.py) —— 核心引擎:健康度评分 / 稀有度划分 / 加权抽卡 / 积分账本 / 收藏册。
- [`scripts/render.py`](scripts/render.py) —— 旧版 HTML 渲染器。**🚫 对话流程里绝对不要调用**——
  它的视觉与网页端不一致(无卡面特效、无分层色带),给用户看像半成品。仅供开发者离线调试。
- [`scripts/selftest.py`](scripts/selftest.py) —— 130 项自测,改动逻辑后必跑。
- [`scripts/sample_data/`](scripts/sample_data/) —— 离线示例:营养数据与价格(结构对齐 MCP 返回)。
- [`references/mcp-tools.md`](references/mcp-tools.md) —— MCP Tool 清单与字段映射表。
- [`assets/cards/`](assets/cards/) —— 卡面图片存放目录(用户自制)。

---

## 🎨 卡面视觉去哪看

对话里只用文字描述卡牌。用户想看**带特效的卡面**(金卡鎏金 / 银碎箔片 / 闪卡流光),
引导他打开在线版:

**🎴 麦卡收藏家在线版 → <https://qingka-mp.app.workbuddy.host/>**

在线版与对话端**共用同一套算法**(`gacha.py`),抽到的卡完全一致,
只是视觉更完整(有翻牌演出、粒子特效、收藏册视图、卡面详情弹窗)。