mcd-gacha-collector · diff
git:20261023.a7ece43 to git:20261023.fb1679c
741 added, 738 removed. Audit A to A.
- ---
- 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` 是双方共享的卡池源
- > (展示卡池共 **98 张**:72 张原卡 + 26 张独立闪卡变体,
- > 覆盖 14 个品类,100% 有官方卡图)。`scripts/gacha.py` 会自动读取该文件,
- > **保证两端抽到的卡牌口径完全一致**。
- >
- > ⚠️ 网页端(展示全品类)与对话端(按用户门店实时菜单)**数据源不同但算法相同**:
- > - **网页**:用 `list-nutrition-foods` 全量 160 项构建,卡池稳定不随时段变化
- > - **对话**:用用户所在门店的 `query-meals` 实时菜单构建,价格准、可直接下单
-
- > 核心纪律:**永远不要自己口算健康度、稀有度或抽卡结果**。
- > 一切以 `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`),与原卡并存、可分别收集。
-
- - 卡池从 72 张原卡扩到 **98 张**(+26 张闪卡变体)
- - 卡名旁有「闪卡版」标签(彩虹渐变底)区分
- - 变体卡复用原卡的图源,但卡面路径独立(便于用户自制)
-
- 出现概率(按稀有度递进):
-
- | 稀有度 | 金卡 | 银碎 | 闪卡 | 无特效 |
- |---|---:|---:|---:|---:|
- | 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`),抽到的卡规则完全一致,
- 只是视觉更完整(有翻牌演出、粒子特效、收藏册视图、卡面详情弹窗)。
-
- ---
-
- ## 🗂️ 网页展示卡池是怎么来的(维护者参考)
-
- 网页端 `tools/gen_web_data.py` 的卡池构建**分四步**,顺序不能乱:
-
- | 步骤 | 做什么 | 为什么这个顺序 |
- |---|---|---|
- | 1 | `build_catalog()` 取全量营养库(156 项 / 14 品类) | 门店菜单**随时段变**(早上只有早餐),营养库与时段无关 |
- | 2 | **剔除无图卡** | 营养库有大量「同款换规格」(热/冰美式×中/大/小杯),官方只有 1-2 个规格有图 |
- | 3 | `build_cards(过滤后的数据)` | 🔑 分层阈值依赖**全池价值分位数**,必须**先剔除再计算**,否则阈值与卡池对不上 |
- | 4 | `expand_flash_variants()` 生成闪卡变体 | 闪卡是独立卡项,剔除完成后才扩展 |
-
- > ⚠️ **最容易踩的坑**:如果写成「先 `build_cards`(全量)→ 再剔除无图卡」,
- > 阈值会按全量算,但最终卡池少了近一半,`parity_check` 会报
- > 「麦香鱼 rarity: UR vs SSR」之类的逐字段不一致。
- > 凡是依赖全池统计量的算法,**筛选必须在计算之前**。
-
- **数据源分工**:
-
- | 端 | 数据源 | 卡池量 | 特点 |
- |---|---|---:|---|
- | 网页展示 | `list-nutrition-foods` 全量 160 项 | 98 张 | 稳定、全品类、100% 有图 |
- | 对话抽卡 | 用户门店 `query-meals` 实时菜单 | 30~90 张 | 时段相关、价格准、可下单 |
+ ---
+ 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` 是双方共享的卡池源
+ > (展示卡池共 **96 张**:73 张原卡 + 23 张独立闪卡变体,
+ > 覆盖 13 个品类,100% 有官方卡图)。`scripts/gacha.py` 会自动读取该文件,
+ > **保证两端抽到的卡牌口径完全一致**。
+ >
+ > ⚠️ 网页端(展示全品类)与对话端(按用户门店实时菜单)**数据源不同但算法相同**:
+ > - **网页**:用 `list-nutrition-foods` 全量 160 项构建,卡池稳定不随时段变化
+ > - **对话**:用用户所在门店的 `query-meals` 实时菜单构建,价格准、可直接下单
+
+ > 核心纪律:**永远不要自己口算健康度、稀有度或抽卡结果**。
+ > 一切以 `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`),与原卡并存、可分别收集。
+
+ - 卡池从 73 张原卡扩到 **96 张**(+23 张闪卡变体)
+ - 卡名旁有「闪卡版」标签(彩虹渐变底)区分
+ - 变体卡复用原卡的图源,但卡面路径独立(便于用户自制)
+
+ 出现概率(按稀有度递进):
+
+ | 稀有度 | 金卡 | 银碎 | 闪卡 | 无特效 |
+ |---|---:|---:|---:|---:|
+ | 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`),抽到的卡规则完全一致,
+ 只是视觉更完整(有翻牌演出、粒子特效、收藏册视图、卡面详情弹窗)。
+
+ ---
+
+ ## 🗂️ 网页展示卡池是怎么来的(维护者参考)
+
+ 网页端 `tools/gen_web_data.py` 的卡池构建**分四步**,顺序不能乱:
+
+ | 步骤 | 做什么 | 为什么这个顺序 |
+ |---|---|---|
+ | 1 | `build_catalog()` 取全量营养库(156 项 / 16 品类,剔除后剩 13) | 门店菜单**随时段变**(早上只有早餐),营养库与时段无关 |
+ | 2 | **剔除无图卡** | 营养库有大量「同款换规格」(热/冰美式×中/大/小杯),官方只有 1-2 个规格有图 |
+ | 3 | `build_cards(过滤后的数据)` | 🔑 分层阈值依赖**全池价值分位数**,必须**先剔除再计算**,否则阈值与卡池对不上 |
+ | 4 | `expand_flash_variants()` 生成闪卡变体 | 闪卡是独立卡项,剔除完成后才扩展 |
+
+ > ⚠️ **最容易踩的坑**:如果写成「先 `build_cards`(全量)→ 再剔除无图卡」,
+ > 阈值会按全量算,但最终卡池少了近一半,`parity_check` 会报
+ > 「麦香鱼 rarity: UR vs SSR」之类的逐字段不一致。
+ > 凡是依赖全池统计量的算法,**筛选必须在计算之前**。
+
+ **数据源分工**:
+
+ | 端 | 数据源 | 卡池量 | 特点 |
+ |---|---|---:|---|
+ | 网页展示 | `list-nutrition-foods` 全量 160 项 | **96 张 / 13 品类** | 稳定、100% 有图 |
+ | 对话抽卡 | 用户门店 `query-meals` 实时菜单 | 30~90 张 | 时段相关、价格准、可下单 |
+
+ > 剔除无图卡后,「雪冰」「创意餐」「风味脆汁鸡」三个品类**全部无图被剔空**,
+ > 所以实际品类从 16 降到 13。这是数据事实,不是缺陷。