---
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`），抽到的卡完全一致，
只是视觉更完整（有翻牌演出、粒子特效、收藏册视图、卡面详情弹窗）。