Skill: hongweisong1999/travel-city-game (version 1.0.12)

--- name: travel-city-game description: > 根据用户输入的城市名，自动调用 flyai 搜索该城市的景点、体验、美食产品， 然后生成一个游戏化的3节点叙事行程 H5 页面。每个节点包含 AI 生成的故事文案 和飞猪真实预订链接。使用方式: /travel-city-game 城市名。 当用户提到 "travel-city-game"、"生成行程"、"旅行叙事"、"游戏化行程"、 想要为某个城市创建探索路线、或者想把旅游景点串成故事线时使用此 Skill。 即使用户只是说"帮我生成XX城市的旅行路线"也应触发。 compatibility: requires: skills: - name: flyai install_hint: "Install from https://clawhub.ai/yealexchen/flyai or run: claude skill install flyai" bins: - python3 - node - flyai --- # City Game — AI 叙事行程生成器 将任意城市的真实旅游产品串联成一条游戏化叙事路线。玩家逐步解锁节点、预订真实飞猪产品、收集奖励，体验"剧情驱动"的旅行方式。 ## 输入 用户提供一个城市名，如 `长沙`、`大理`、`厦门` 等。从用户消息中提取城市名，赋值给变量 `CITY`。 ## 完整工作流程 ### Step 0: 验证 flyai CLI 已就绪 本 Skill 依赖 `flyai` Skill 提供的 CLI 工具。`flyai` 必须由用户预先安装（不在运行时自动安装，以避免供应链安全风险）。 执行以下命令确认 flyai 可用： ```bash flyai --help ``` 如果命令不存在（返回 "command not found"），**停止执行并提示用户**： > ⚠️ 未检测到 flyai CLI。请先安装 flyai Skill： > - 方式一：访问 https://clawhub.ai/yealexchen/flyai 安装 > - 方式二：在终端执行 `claude skill install flyai` > > 安装完成后重新运行本 Skill。 **绝对不能在 Skill 流程中自动执行 `npm install` 安装 flyai**——这会引入未经用户确认的第三方代码，存在供应链安全风险。同样，**绝对不能跳过 flyai 搜索直接编造数据。** #### API 限流处理 在后续 Step 1 的搜索过程中，如果 flyai 返回了限流相关的错误（如 HTTP 429、"rate limit"、"quota exceeded" 或返回结果为空且提示频率过高），**停止继续请求并提示用户获取专属 API Key**： > ⚠️ flyai API 请求被限流，免费额度可能已用尽。请按以下步骤获取 API Key： > 1. 登录 https://flyai.open.fliggy.com/ > 2. 注册/登录后在控制台获取你的专属 API Key > 3. 将 API Key 配置到环境变量：`export FLYAI_API_KEY="你的Key"` > 4. 配置完成后重新运行本 Skill 设置好 API Key 后，flyai CLI 会自动使用该 Key 进行认证，不再受公共限流约束。 ### Step 1: 用 flyai CLI 搜索 3 类旅游产品 #### 安全执行 flyai 命令（避免 Shell 注入） ⚠️ **严禁直接将 CITY 变量拼接到 Bash 命令字符串中执行**。必须使用以下安全方式： **方式一：使用 Python 子进程调用（推荐，最安全）** 用 Python 执行 flyai 命令，参数作为列表传递（不经过 Shell 解析）： ```python import subprocess, json def flyai_search(args): """安全调用 flyai CLI，参数作为列表传入，Shell 无法解析特殊字符""" result = subprocess.run( ["flyai"] + args, capture_output=True, text=True, timeout=30 ) if result.returncode != 0: print(f"Error: {result.stderr}") return None return json.loads(result.stdout) # 示例：搜索历史古迹 data = flyai_search(["search-poi", "--city-name", CITY, "--category", "历史古迹"]) ``` **方式二：如果必须用 Bash，使用单引号包裹所有变量** ```bash # 使用单引号包裹 CITY 和其他参数值，防止 Shell 解析 flyai search-poi --city-name 'CITY' --category '历史古迹' ``` > ⚠️ 方式二仍有风险（如果 CITY 本身包含单引号），优先使用方式一。 **绝对禁止的做法**： ```bash # ❌ 危险！CITY 如果包含 " 或 ; 等字符会导致命令注入 flyai search-poi --city-name "CITY" --category "历史古迹" ``` --- 使用上述安全方式执行以下 flyai 搜索。每条命令返回 JSON 到 stdout。 **重要：两种命令的返回结构不同：** - `search-poi` 返回: `data.itemList[].{name, address, mainPic, jumpUrl, ticketInfo.ticketName}` - `keyword-search` 返回: `data.itemList[].info.{title, picUrl, jumpUrl, price, tags}` --- **搜索 A — 节点1（文化探索）：历史/文化景点** 首选命令（使用单引号）： ```bash flyai search-poi --city-name 'CITY' --category '历史古迹' ``` 如果返回结果少于 2 个，依次尝试： ```bash flyai search-poi --city-name 'CITY' --category '人文古迹' flyai search-poi --city-name 'CITY' --category '博物馆' flyai keyword-search --query 'CITY 著名景点门票' ``` 从结果中选 **1 个最佳产品**，提取字段： - `skuName`: 来自 `name`（search-poi）或 `info.title`（keyword-search） - `jumpUrl`: 来自 `jumpUrl`（search-poi）或 `info.jumpUrl`（keyword-search） - `picUrl`: 来自 `mainPic`（search-poi）或 `info.picUrl`（keyword-search） - `address`: 来自 `address`（search-poi）或自行根据产品名推断（keyword-search 无 address 字段） --- **搜索 B — 节点2（特色体验）：演出/体验/特色项目** 首选命令（使用单引号）： ```bash flyai keyword-search --query 'CITY 特色体验 演出' ``` 如果返回结果不理想，依次尝试： ```bash flyai keyword-search --query 'CITY 非遗体验' flyai search-poi --city-name 'CITY' --category '演出赛事' flyai keyword-search --query 'CITY 休闲玩乐' ``` 从结果中选 **2 个产品**（主产品 + 备选产品，备选用于 Step 4 意外引擎）。 --- **搜索 C — 节点3（美食收官）：美食/夜游/特色民宿** 首选命令（使用单引号）： ```bash flyai keyword-search --query 'CITY 美食体验' ``` 如果返回结果不理想，依次尝试： ```bash flyai keyword-search --query 'CITY 夜游' flyai keyword-search --query 'CITY 特色民宿' flyai keyword-search --query 'CITY 网红餐厅' ``` 从结果中选 **1 个最佳产品**。 --- ### Step 2: 验证所选产品 确认每个所选产品都满足以下条件（必须全部满足）： - `jumpUrl` 存在且为有效 URL（非空字符串） - `picUrl` 存在且为有效图片 URL - `skuName` 非空且有地域特色 如果某个产品不满足，从同一搜索结果中选择下一个产品，或换关键词重新搜索。 ### Step 3: 为每个节点生成叙事故事 为 3 个节点分别撰写沉浸式第二人称故事文案（80-120 字），格式要求： - 使用第二人称"你"作为主角 - 包含场景描写和感官细节（视觉、听觉、味觉、触觉至少 2 种） - 包含一个 NPC 角色的对话（如守门大爷、老板娘、茶馆掌柜、夜市摊主） - 以省略号或悬念结尾 - 融入该城市独有的文化元素和地标特征（不要写任何城市都通用的万能文案） 三章结构： 1. **第一章** — 到达城市，被某个历史/文化元素吸引，节奏舒缓 2. **第二章** — 发现城市的隐藏魅力，节奏加快，情节推进 3. **终章** — 夜幕降临，在美食或夜景中完成旅程，余韵悠长 ### Step 4: 构建意外引擎数据 为节点2设置意外引擎，模拟"产品售罄→AI 重新搜索→剧情反转"的体验。使用 Step 1 搜索 B 的备选产品作为替代： ```json { "reason": "很抱歉，{主产品名} 的热门场次已售罄，节假日期间一票难求...", "aiThinking": "FlyAI 正在重新搜索 CITY 周边体验项目...\n\n发现了一个隐藏宝藏：{备选产品名}！", "newStory": "为备选产品写的新故事文案（80-120字）", "newSkuName": "备选产品的 skuName", "newJumpUrl": "备选产品的 jumpUrl", "newPicUrl": "备选产品的 picUrl", "newAddress": "备选产品的 address", "compensationReward": { "type": "coupon", "name": "剧情反转补偿券", "icon": "🎭" } } ``` ### Step 5: 组装数据并生成 HTML **5a. 构建 LOADING_STEPS 数组** 反映实际执行的 flyai 搜索命令： ```json [ "> flyai search-poi --city-name \"CITY\" --category \"历史古迹\"", " ✓ 发现 N 个文化景点", "> flyai keyword-search --query \"CITY 特色体验 演出\"", " ✓ 发现 N 个特色体验", "> flyai keyword-search --query \"CITY 美食体验\"", " ✓ 发现 N 个美食推荐", "> AI 正在编织叙事故事线...", " ✓ 3 段沉浸式故事已生成", "> 意外引擎已就绪" ] ``` **5b. 构建 NODES 数组** 3 个节点对象，结构如下： ```json [ { "id": "{城市拼音}_node1", "title": "节点标题（来自产品名或景点名）", "chapter": "第一章", "story": "Step 3 生成的故事文案", "skuName": "飞猪产品名", "jumpUrl": "https://...（来自 flyai 搜索结果）", "picUrl": "https://...（来自 flyai 搜索结果）", "address": "具体地址", "reward": { "type": "coupon", "name": "XX体验券", "icon": "🎫" }, "status": "unlocked", "willTriggerRedirect": false, "redirectData": null }, { "id": "{城市拼音}_node2", "title": "节点标题", "chapter": "第二章", "story": "Step 3 生成的故事文案", "skuName": "飞猪产品名", "jumpUrl": "https://...", "picUrl": "https://...", "address": "具体地址", "reward": { "type": "coupon", "name": "XX探秘券", "icon": "🎪" }, "status": "locked", "willTriggerRedirect": true, "redirectData": { "reason": "...", "aiThinking": "...", "newStory": "...", "newSkuName": "...", "newJumpUrl": "...", "newPicUrl": "...", "newAddress": "...", "compensationReward": { "type": "coupon", "name": "剧情反转补偿券", "icon": "🎭" } } }, { "id": "{城市拼音}_node3", "title": "节点标题", "chapter": "终章", "story": "Step 3 生成的故事文案", "skuName": "飞猪产品名", "jumpUrl": "https://...", "picUrl": "https://...", "address": "具体地址", "reward": { "type": "title", "name": "CITY行者 称号", "icon": "🏆" }, "status": "locked", "willTriggerRedirect": false, "redirectData": null } ] ``` **5c. 读取模板并替换占位符** ⚠️ **强制要求：必须使用 Bash 工具读取 `assets/template.html` 的完整原始内容，不得从记忆重建或自行编写 HTML。** ```bash cat assets/template.html ``` 读取到文件内容后，**仅**对以下 7 个占位符做字符串替换，其余所有字符（包括所有 `<style>` CSS 样式、`<script>` 逻辑、HTML 结构标签）一律保持原样，不得增删或修改： | 占位符 | 替换为 | 示例 | |--------|--------|------| | `{{CITY_NAME}}` | 城市名 | `长沙` | | `{{CITY_TITLE}}` | 城市名 + "篇" | `长沙篇` | | `{{CITY_BADGE}}` | 城市名 + " · " + 主题短语 | `长沙 · 湘江寻味` | | `{{LOADING_STEPS_JSON}}` | 5a 的 JSON 数组（合法 JS） | `["> flyai ...", ...]` | | `{{FINISH_TITLE}}` | 城市名 + "副本通关!" | `长沙副本通关!` | | `{{FINISH_TEXT}}` | 通关提示文案 | `你已解锁长沙全部隐藏...` | | `{{NODES_JSON}}` | 5b 的 JSON 数组（合法 JS） | `[{...}, {...}, {...}]` | **关键：JSON 必须是合法的 JavaScript 字面量，不能有语法错误（注意转义引号、换行符）。** > 🚫 严禁行为：不得重新生成 HTML 骨架、不得修改 CSS 颜色/布局/动画、不得增减 `<script>` 中的函数、不得对模板做任何"优化"或"美化"。输出文件的内容必须与模板文件逐字一致，仅 7 处占位符被替换。 ### Step 6: 输出文件并启动本地预览 将替换后的 HTML 保存到 outputs 目录： ``` outputs/city-game-CITY.html ``` 保存后，**必须使用 Bash 工具在后台启动本地 HTTP 服务**，自动打开浏览器预览。不要用 `file://` 协议（会被浏览器安全策略拦截），也不要只是告诉用户手动打开。 ```bash python3 scripts/serve.py "outputs/city-game-CITY.html" ``` > `scripts/serve.py` 会自动找到空闲端口、启动 HTTP 服务、打开浏览器访问 `http://localhost:{port}/city-game-CITY.html`。 > 这条命令需要用 `run_in_background` 模式执行（因为服务会持续运行），然后立即向用户输出链接。 执行后向用户展示： - 本地预览链接（`http://localhost:{port}/...`） - 3 个节点的简要概述 - 提示：关闭终端或按 Ctrl+C 即可停止服务 - 提示用户：如需公网访问，可使用下方的一键部署方式 ### Step 7（可选）: 部署到公网 生成的 HTML 是纯静态单文件，无需后端，可直接部署到任意静态托管平台。 **方式一：surge.sh（推荐，最快）** ```bash # 首次使用需安装：npm install -g surge # 注意：域名不支持中文，CITY_PINYIN 需替换为城市名拼音（如长沙→changsha，大理→dali，厦门→xiamen） mkdir -p /tmp/city-game-deploy && cp outputs/city-game-CITY.html /tmp/city-game-deploy/index.html surge /tmp/city-game-deploy --domain city-game-CITY_PINYIN.surge.sh ``` 部署后会得到一个公网 URL，如 `https://city-game-changsha.surge.sh`，任何人都可以访问。 **方式二：Netlify Drop** 打开 https://app.netlify.com/drop ，将生成的 HTML 文件拖进去即可，会自动生成一个公网链接。 **方式三：GitHub Pages** 将 HTML 推送到 GitHub 仓库的 `gh-pages` 分支，即可通过 `https://用户名.github.io/仓库名/` 访问。 询问用户是否需要部署。如果用户确认，优先使用 surge.sh 方式自动执行。 ## 重要规则 1. **真实数据**：所有 jumpUrl、picUrl 必须来自 flyai 搜索结果，严禁编造 2. **降级策略**：如果某个搜索无结果，换关键词再搜，至多重试 3 次 3. **JSON 安全**：故事文案中如果包含双引号 `"`，必须转义为 `\"`；换行用 `\n` 不用真实换行 4. **地域特色**：每个城市的故事必须有独特的文化元素，不可套用通用模板 5. **节点 ID**：使用 `{城市拼音}_node1`、`{城市拼音}_node2`、`{城市拼音}_node3` 格式 6. **严格使用模板**：生成 HTML 时必须先用 Bash `cat assets/template.html` 读取模板文件，再做占位符替换后写出；禁止凭记忆重建或自行编写 HTML、CSS、JS；除 7 个 `{{...}}` 占位符外，输出文件的每一个字符必须与 `assets/template.html` 完全一致

path: assets/template.html
content: <!-- ⚠️ 【只读模板文件 - READ-ONLY TEMPLATE】⚠️
     此文件是源模板，AI/Copilot 在任何情况下都 **禁止修改** 此文件。
     - 只能"读取"此文件内容，不能"写入"或"编辑"。
     - 输出结果必须写入 outputs/ 目录（如 outputs/city-game-长沙.html）。
     - 修改此文件将导致后续所有城市生成结果的样式/结构损坏。
     DO NOT EDIT THIS FILE. Write output to outputs/ only.
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>旅行游戏副本生成器 · {{CITY_NAME}} - Hackathon Demo</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  body {
    font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
    background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
    min-height: 100vh; color: #e0e0e0;
    overflow-x: hidden;
  }

  /* ==================== LOADING SCREEN ==================== */
  .loading-screen {
    position: fixed; top:0; left:0; right:0; bottom:0;
    z-index: 200; display: flex; flex-direction: column;
    justify-content: center; align-items: center;
    background: linear-gradient(135deg, #0a0a1a 0%, #1a1a3e 50%, #0f2040 100%);
    transition: opacity 0.8s ease, transform 0.8s ease;
  }
  .loading-screen.hide { opacity: 0; transform: scale(1.1); pointer-events: none; }
  .loading-logo {
    font-size: 48px; font-weight: 800; color: #fff;
    text-shadow: 0 0 40px rgba(255,107,107,0.6), 0 0 80px rgba(255,107,107,0.3);
    animation: pulseGlow 2s infinite ease-in-out; letter-spacing: 6px;
  }
  @keyframes pulseGlow {
    0%,100% { text-shadow: 0 0 40px rgba(255,107,107,0.6), 0 0 80px rgba(255,107,107,0.3); }
    50% { text-shadow: 0 0 60px rgba(255,107,107,0.9), 0 0 120px rgba(255,107,107,0.5); }
  }
  .loading-tagline { font-size: 14px; color: #aaa; margin-top: 12px; letter-spacing: 2px; }
  .loading-text { color: #aaa; font-size: 14px; margin-top: 24px; }
  .loading-steps { margin-top: 16px; text-align: left; }
  .loading-step { color: #666; font-size: 13px; padding: 4px 0; transition: color 0.3s ease; font-family: monospace; }
  .loading-step.active { color: #ff6b6b; }
  .loading-step.done { color: #2ecc71; }
  .loading-step.done::before { content: "\u2713 "; }
  .loading-spinner {
    width: 40px; height: 40px; border: 3px solid rgba(255,107,107,0.2);
    border-top-color: #ff6b6b; border-radius: 50%; animation: spin 1s linear infinite;
    margin-top: 30px;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  .loading-start-btn {
    margin-top: 30px; padding: 14px 48px; border-radius: 30px;
    background: linear-gradient(135deg, #ff6b6b, #ee5a24); color: #fff;
    font-size: 16px; font-weight: 700; border: none; cursor: pointer;
    box-shadow: 0 6px 25px rgba(255,107,107,0.4); transition: all 0.3s ease;
    letter-spacing: 2px; display: none;
  }
  .loading-start-btn.show { display: inline-block; animation: fadeUp 0.4s ease; }
  .loading-start-btn:hover { transform: scale(1.05); }
  .loading-flyai-tag { position: absolute; bottom: 30px; font-size: 12px; color: #555; }
  .loading-flyai-tag a { color: #ff6b6b; text-decoration: none; }

  @keyframes fadeUp { from { opacity:0; transform: translateY(10px); } to { opacity:1; transform: translateY(0); } }

  /* ==================== HEADER ==================== */
  .header { text-align: center; padding: 30px 20px 10px; }
  .header h1 { font-size: 28px; color: #fff; text-shadow: 0 0 20px rgba(255,107,107,0.5); }
  .header .subtitle { font-size: 14px; color: #aaa; margin-top: 6px; }
  .badge { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 11px; margin-top: 8px; }
  .badge-flyai { background: rgba(46,204,113,0.15); color: #2ecc71; border: 1px solid rgba(46,204,113,0.4); }

  /* ==================== DATA SOURCE ==================== */
  .data-source { text-align: center; padding: 8px; font-size: 11px; color: #666; }
  .data-source .dot {
    display: inline-block; width: 6px; height: 6px; border-radius: 50%;
    background: #2ecc71; margin-right: 4px; vertical-align: middle; animation: blink 2s infinite;
  }
  @keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0.3; } }

  /* ==================== PROGRESS ==================== */
  .progress-section { padding: 20px; }
  .progress-bar-bg { background: rgba(255,255,255,0.1); border-radius: 20px; height: 12px; overflow: hidden; }
  .progress-bar-fill { height: 100%; border-radius: 20px; transition: width 0.8s ease; background: linear-gradient(90deg, #ff6b6b, #feca57); }
  .progress-label { text-align: center; font-size: 13px; color: #aaa; margin-top: 6px; }

  /* ==================== NODE CARDS ==================== */
  .nodes { padding: 0 20px 20px; }
  .node-card {
    background: rgba(255,255,255,0.06); border-radius: 16px;
    margin-bottom: 16px; overflow: hidden;
    border: 1px solid rgba(255,255,255,0.08); transition: all 0.3s ease;
  }
  .node-card.active { border-color: #ff6b6b; box-shadow: 0 0 20px rgba(255,107,107,0.2); }
  .node-card.completed { border-color: #2ecc71; opacity: 0.85; }
  .node-card.locked { opacity: 0.4; filter: grayscale(0.5); }
  .node-card.redirected { border-color: #feca57; box-shadow: 0 0 20px rgba(254,202,87,0.2); }
  .node-img { width: 100%; height: 180px; object-fit: cover; }
  .node-body { padding: 16px; }
  .node-index {
    display: inline-block; width: 24px; height: 24px; border-radius: 50%;
    text-align: center; line-height: 24px; font-size: 12px; font-weight: bold; margin-right: 8px;
  }
  .node-card.active .node-index { background: #ff6b6b; color: #fff; }
  .node-card.completed .node-index { background: #2ecc71; color: #fff; }
  .node-card.locked .node-index { background: rgba(255,255,255,0.2); color: #888; }
  .node-card.redirected .node-index { background: #feca57; color: #333; }
  .node-title { font-size: 18px; font-weight: 600; color: #fff; display: inline; }
  .node-status-tag { display: inline-block; padding: 2px 8px; border-radius: 8px; font-size: 11px; margin-left: 8px; vertical-align: middle; }
  .tag-active { background: rgba(255,107,107,0.2); color: #ff6b6b; }
  .tag-completed { background: rgba(46,204,113,0.2); color: #2ecc71; }
  .tag-locked { background: rgba(255,255,255,0.1); color: #888; }
  .tag-redirected { background: rgba(254,202,87,0.2); color: #feca57; }
  .node-story { font-size: 14px; color: #bbb; margin: 10px 0; line-height: 1.6; font-style: italic; }
  .node-meta { font-size: 12px; color: #888; margin-bottom: 12px; }

  .btn {
    display: inline-block; padding: 10px 24px; border-radius: 25px;
    font-size: 14px; font-weight: 600; cursor: pointer; border: none;
    transition: all 0.3s ease; text-decoration: none; color: #fff;
  }
  .btn-book { background: linear-gradient(135deg, #ff6b6b, #ee5a24); }
  .btn-book:hover { transform: scale(1.05); box-shadow: 0 4px 15px rgba(255,107,107,0.4); }
  .btn-verify { background: linear-gradient(135deg, #2ecc71, #27ae60); }
  .btn-verify:hover { transform: scale(1.05); }
  .btn-disabled { background: #444; color: #888; cursor: not-allowed; }
  .btn-redirect { background: linear-gradient(135deg, #feca57, #f39c12); color: #333; }
  .btn-group { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }

  /* ==================== REWARD TOAST ==================== */
  .reward-toast {
    position: fixed; top: -100px; left: 50%; transform: translateX(-50%);
    background: linear-gradient(135deg, #feca57, #f39c12); color: #333;
    padding: 16px 32px; border-radius: 16px; font-weight: 600;
    box-shadow: 0 8px 30px rgba(254,202,87,0.4); z-index: 300;
    transition: top 0.5s ease; font-size: 16px; text-align: center; white-space: nowrap;
  }
  .reward-toast.show { top: 30px; }

  /* ==================== REDIRECT OVERLAY ==================== */
  .redirect-overlay {
    display: none; position: fixed; top:0; left:0; right:0; bottom:0;
    background: rgba(0,0,0,0.88); z-index: 90;
    justify-content: center; align-items: center; text-align: center;
  }
  .redirect-overlay.show { display: flex; }
  .redirect-content {
    background: rgba(255,255,255,0.08); border-radius: 20px; padding: 40px;
    max-width: 380px; border: 1px solid rgba(254,202,87,0.3);
  }
  .redirect-content h2 { color: #feca57; font-size: 22px; margin-bottom: 16px; }
  .redirect-content p { color: #ccc; line-height: 1.7; margin-bottom: 20px; text-align: left; min-height: 60px; white-space: pre-wrap; }
  .typing-indicator span {
    display: inline-block; width: 8px; height: 8px; border-radius: 50%;
    background: #feca57; margin: 0 3px; animation: bounce 1.4s infinite ease-in-out;
  }
  .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
  .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
  @keyframes bounce { 0%,80%,100% { transform: scale(0.6); opacity: 0.4; } 40% { transform: scale(1); opacity: 1; } }
  .redirect-new-pic { width: 100%; height: 120px; object-fit: cover; border-radius: 12px; margin-bottom: 16px; display: none; }

  /* ==================== SUMMARY ==================== */
  .summary { display: none; padding: 20px; text-align: center; }
  .summary.show { display: block; animation: fadeUp 0.6s ease; }
  .summary h2 { color: #feca57; font-size: 24px; margin-bottom: 10px; }
  .summary .stat { font-size: 42px; color: #fff; font-weight: bold; }
  .summary .stat-label { font-size: 14px; color: #aaa; }
  .summary-rewards { margin-top: 16px; }

  /* ==================== FOOTER ==================== */
  .footer { text-align: center; padding: 20px; font-size: 12px; color: #555; }
  .footer a { color: #ff6b6b; text-decoration: none; }

  /* ==================== REWARDS DRAWER ==================== */
  .rewards-drawer {
    background: rgba(255,255,255,0.06); border-radius: 16px; margin: 0 20px 16px;
    padding: 16px; border: 1px solid rgba(254,202,87,0.2);
  }
  .rewards-drawer h3 { color: #feca57; font-size: 14px; margin-bottom: 10px; }
  .reward-item {
    display: inline-block; padding: 6px 14px; border-radius: 20px;
    background: rgba(254,202,87,0.1); color: #feca57; font-size: 12px;
    margin: 4px; border: 1px dashed rgba(254,202,87,0.3);
  }
  .reward-item.empty { color: #666; border-color: #444; background: transparent; }

  /* ==================== CONFETTI ==================== */
  .confetti-container { position: fixed; top:0; left:0; right:0; bottom:0; pointer-events: none; z-index: 120; }
  .confetti { position: absolute; width: 10px; height: 10px; top: -10px; animation: confettiFall 3s ease-out forwards; }
  @keyframes confettiFall { 0% { transform: translateY(0) rotate(0deg); opacity: 1; } 100% { transform: translateY(100vh) rotate(720deg); opacity: 0; } }

  /* ==================== GAME CONTAINER ==================== */
  .game-container { display: none; }
  .game-container.show { display: block; animation: fadeUp 0.5s ease; }
</style>
</head>
<body>

<!-- ========== LOADING SCREEN ========== -->
<div class="loading-screen" id="loadingScreen">
  <div class="loading-logo">旅行游戏副本生成器</div>
  <div class="loading-tagline">AI 叙事 x 飞猪真实预订 x 游戏化行程</div>
  <div class="loading-spinner"></div>
  <div class="loading-text" id="loadingText">AI 正在为你编织「{{CITY_NAME}}」故事线...</div>
  <div class="loading-steps" id="loadingSteps"></div>
  <button class="loading-start-btn" id="startBtn" onclick="startGame()">开启冒险</button>
  <div class="loading-flyai-tag">
    Powered by <a href="https://open.fly.ai/">fly.ai</a> MCP | Hackathon 2026
  </div>
</div>

<!-- ========== GAME UI ========== -->
<div class="game-container" id="gameContainer">
  <div class="reward-toast" id="rewardToast"></div>
  <div class="confetti-container" id="confettiContainer"></div>

  <!-- Redirect Overlay -->
  <div class="redirect-overlay" id="redirectOverlay">
    <div class="redirect-content">
      <h2>剧情反转!</h2>
      <div class="typing-indicator" id="typingIndicator"><span></span><span></span><span></span></div>
      <img class="redirect-new-pic" id="redirectNewPic" alt="新目的地">
      <p id="redirectText" style="display:none"></p>
      <button class="btn btn-redirect" id="redirectBtn" style="display:none" onclick="acceptRedirect()">接受新任务</button>
    </div>
  </div>

  <!-- Header -->
  <div class="header">
    <h1 id="gameTitle">旅行游戏副本生成器 · {{CITY_TITLE}}</h1>
    <div class="subtitle" id="gameSubtitle">{{CITY_BADGE}}</div>
    <div class="badge badge-flyai">LIVE DATA via fly.ai</div>
  </div>

  <!-- Data Source -->
  <div class="data-source">
    <span class="dot"></span> 数据来源: flyai MCP 实时搜索 (keyword-search / search-poi)
  </div>

  <!-- Progress -->
  <div class="progress-section">
    <div class="progress-bar-bg">
      <div class="progress-bar-fill" id="progressBar" style="width: 0%"></div>
    </div>
    <div class="progress-label" id="progressLabel">副本进度 0/3</div>
  </div>

  <!-- Rewards -->
  <div class="rewards-drawer">
    <h3>奖励背包</h3>
    <div id="rewardsList"><span class="reward-item empty">暂无奖励 - 完成节点解锁</span></div>
  </div>

  <!-- Nodes -->
  <div class="nodes" id="nodeContainer"></div>

  <!-- Summary -->
  <div class="summary" id="summary">
    <h2 id="summaryTitle">{{FINISH_TITLE}}</h2>
    <div class="stat" id="summaryScore">100</div>
    <div class="stat-label">探索积分</div>
    <div class="summary-rewards" id="summaryRewards"></div>
    <p id="summaryText" style="color:#aaa; margin-top:16px">{{FINISH_TEXT}}</p>
    <div style="display:flex; gap:10px; justify-content:center; margin-top:20px; flex-wrap:wrap">
      <button class="btn btn-book" onclick="resetGame()">再来一次</button>
    </div>
  </div>

  <div class="footer">
    Based on <a href="https://open.fly.ai/">fly.ai</a> real-time results | Hackathon Demo 2026
  </div>
</div>

<!-- 安全兜底：若主脚本因模板占位符未替换等原因解析失败，12秒后强制显示开始按钮 -->
<script>
  setTimeout(function() {
    try {
      var spinner = document.querySelector('.loading-spinner');
      var btn = document.getElementById('startBtn');
      var txt = document.getElementById('loadingText');
      if (spinner) spinner.style.display = 'none';
      if (txt) txt.style.display = 'none';
      if (btn && !btn.classList.contains('show')) btn.classList.add('show');
    } catch(e) {}
  }, 12000);
</script>

<script>
// ============================================================
// NODE DATA — 由 flyai 实时搜索结果 + AI 叙事生成
// ============================================================
var LOADING_STEPS = {{LOADING_STEPS_JSON}};

var NODES_ORIGINAL = {{NODES_JSON}};

var NODES = JSON.parse(JSON.stringify(NODES_ORIGINAL));
var gameState = { completedCount: 0, totalScore: 0, rewards: [], currentNode: 0 };

// ============================================================
// LOADING ANIMATION
// ============================================================
(function() {
  var stepsHtml = '';
  for (var i = 0; i < LOADING_STEPS.length; i++) {
    stepsHtml += '<div class="loading-step" id="ls' + i + '">' + LOADING_STEPS[i] + '</div>';
  }
  document.getElementById('loadingSteps').innerHTML = stepsHtml;

  var idx = 0;
  function nextStep() {
    if (idx > 0) {
      document.getElementById('ls' + (idx - 1)).classList.remove('active');
      document.getElementById('ls' + (idx - 1)).classList.add('done');
    }
    if (idx < LOADING_STEPS.length) {
      document.getElementById('ls' + idx).classList.add('active');
      idx++;
      setTimeout(nextStep, 500 + Math.random() * 500);
    } else {
      // 所有步骤完成，隐藏加载圈和加载文字，显示开始按钮
      document.querySelector('.loading-spinner').style.display = 'none';
      document.getElementById('loadingText').style.display = 'none';
      document.getElementById('startBtn').classList.add('show');
    }
  }
  setTimeout(nextStep, 600);
})();

function startGame() {
  var ls = document.getElementById('loadingScreen');
  ls.classList.add('hide');
  setTimeout(function() {
    ls.style.display = 'none';
    document.getElementById('gameContainer').classList.add('show');
    renderAll();
  }, 800);
}

// ============================================================
// GAME LOGIC
// ============================================================
function handleBook(nodeIndex) {
  var node = NODES[nodeIndex];
  setTimeout(function() {
    if (node.status === 'unlocked') {
      node.status = 'in_progress';
      renderAll();
    }
  }, 300);
  return true;
}

function handleViewOrder(nodeIndex) { return true; }

function handleVerify(nodeIndex) {
  var node = NODES[nodeIndex];
  if (node.status !== 'in_progress') return;

  if (node.willTriggerRedirect && !node._redirected) {
    triggerRedirect(nodeIndex);
    return;
  }

  node.status = 'completed';
  gameState.completedCount++;
  gameState.totalScore += 30 + (node._redirected ? 10 : 0);

  gameState.rewards.push(node.reward);
  if (node._compensationReward) {
    gameState.rewards.push(node._compensationReward);
  }
  showReward(node.reward.icon + ' ' + node.reward.name);

  if (nodeIndex + 1 < NODES.length) {
    NODES[nodeIndex + 1].status = 'unlocked';
  }
  renderAll();

  // 节点完成后，平滑滚动到下一章节点（改善浏览体验）
  if (nodeIndex + 1 < NODES.length) {
    (function(nextIdx) {
      setTimeout(function() {
        var nextCard = document.getElementById('node-card-' + nextIdx);
        if (nextCard) {
          nextCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
        }
      }, 400); // 等 renderAll 重绘完成后再滚动
    })(nodeIndex + 1);
  }

  if (gameState.completedCount === NODES.length) {
    setTimeout(function() { showSummary(); }, 1200);
  }
}

// ============================================================
// UI RENDERING
// ============================================================
function renderAll() {
  renderNodes();
  renderProgress();
  renderRewards();
}

function renderNodes() {
  var container = document.getElementById('nodeContainer');
  var html = '';

  for (var i = 0; i < NODES.length; i++) {
    var node = NODES[i];
    var statusClass = (node.status === 'unlocked' || node.status === 'in_progress') ? 'active'
      : node.status === 'completed' ? 'completed'
      : node.status === 'redirected' ? 'redirected'
      : 'locked';

    var statusTextMap = { unlocked: '待出发', in_progress: '进行中', completed: '已完成', redirected: '剧情反转!', locked: '未解锁' };
    var tagClassMap = { unlocked: 'tag-active', in_progress: 'tag-active', completed: 'tag-completed', redirected: 'tag-redirected', locked: 'tag-locked' };

    var buttons = '';
    if (node.status === 'unlocked') {
      buttons = '<div class="btn-group"><a class="btn btn-book" href="' + node.jumpUrl + '" target="_blank" onclick="handleBook(' + i + ')">前往预订</a></div>';
    } else if (node.status === 'in_progress') {
      buttons = '<div class="btn-group">' +
        '<a class="btn btn-book" href="' + node.jumpUrl + '" target="_blank" onclick="handleViewOrder(' + i + ')">查看订单</a>' +
        '<button class="btn btn-verify" onclick="handleVerify(' + i + ')">确认到店 (模拟核销)</button>' +
        '</div>';
    } else if (node.status === 'completed') {
      buttons = '<div class="btn-group"><span class="btn btn-disabled">✅ 已完成</span></div>';
    } else {
      buttons = '<div class="btn-group"><span class="btn btn-disabled">🔒 完成上一节点解锁</span></div>';
    }

    html += '<div class="node-card ' + statusClass + '" id="node-card-' + i + '">' +
      '<img class="node-img" src="' + node.picUrl + '" alt="' + node.title + '" onerror="this.style.background=\'#333\';this.style.height=\'80px\'">' +
      '<div class="node-body">' +
        '<span class="node-index">' + (i + 1) + '</span>' +
        '<span class="node-title">' + node.chapter + ': ' + node.title + '</span>' +
        '<span class="node-status-tag ' + tagClassMap[node.status] + '">' + statusTextMap[node.status] + '</span>' +
        '<p class="node-story">"' + node.story + '"</p>' +
        '<p class="node-meta">📍 ' + node.address + ' &nbsp;|&nbsp; 🎫 ' + node.skuName + '</p>' +
        buttons +
      '</div></div>';
  }

  container.innerHTML = html;
}

function renderProgress() {
  var pct = NODES.length > 0 ? Math.round((gameState.completedCount / NODES.length) * 100) : 0;
  document.getElementById('progressBar').style.width = pct + '%';
  document.getElementById('progressLabel').textContent =
    '副本进度 ' + gameState.completedCount + '/' + NODES.length +
    (gameState.totalScore > 0 ? ' | 探索积分 ' + gameState.totalScore : '');
}

function renderRewards() {
  var el = document.getElementById('rewardsList');
  if (gameState.rewards.length === 0) {
    el.innerHTML = '<span class="reward-item empty">暂无奖励 - 完成节点解锁</span>';
  } else {
    var h = '';
    for (var i = 0; i < gameState.rewards.length; i++) {
      var r = gameState.rewards[i];
      h += '<span class="reward-item">' + r.icon + ' ' + r.name + '</span>';
    }
    el.innerHTML = h;
  }
}

// ============================================================
// REDIRECT ENGINE (意外引擎)
// ============================================================
function triggerRedirect(nodeIndex) {
  var overlay = document.getElementById('redirectOverlay');
  var textEl = document.getElementById('redirectText');
  var btnEl = document.getElementById('redirectBtn');
  var typingEl = document.getElementById('typingIndicator');
  var newPicEl = document.getElementById('redirectNewPic');
  var node = NODES[nodeIndex];

  overlay.classList.add('show');
  textEl.style.display = 'none';
  textEl.textContent = '';
  btnEl.style.display = 'none';
  newPicEl.style.display = 'none';
  typingEl.style.display = 'block';

  setTimeout(function() {
    typingEl.style.display = 'none';
    textEl.style.display = 'block';

    var fullText = node.redirectData.reason + '\n\n' + node.redirectData.aiThinking + '\n\n' + node.redirectData.newStory;
    var charIndex = 0;

    function typeNext() {
      if (charIndex < fullText.length) {
        textEl.textContent += fullText[charIndex];
        charIndex++;
        setTimeout(typeNext, 20);
      } else {
        newPicEl.src = node.redirectData.newPicUrl;
        newPicEl.style.display = 'block';
        btnEl.style.display = 'inline-block';
        window._pendingRedirectNode = nodeIndex;
      }
    }
    typeNext();
  }, 1800);
}

function acceptRedirect() {
  var overlay = document.getElementById('redirectOverlay');
  overlay.classList.remove('show');
  var nodeIndex = window._pendingRedirectNode;
  var node = NODES[nodeIndex];
  var rd = node.redirectData;

  node._redirected = true;
  node.story = rd.newStory;
  node.skuName = rd.newSkuName;
  node.jumpUrl = rd.newJumpUrl;
  node.picUrl = rd.newPicUrl;
  node.address = rd.newAddress;
  node._compensationReward = rd.compensationReward;
  // 补偿奖励在 handleVerify 完成时统一添加，此处只做提示
  showReward(rd.compensationReward.icon + ' ' + rd.compensationReward.name + ' (补偿)');

  node.status = 'redirected';
  renderAll();
  setTimeout(function() {
    node.status = 'in_progress';
    renderAll();
  }, 800);
}

// ============================================================
// REWARDS & EFFECTS
// ============================================================
function showReward(text) {
  var toast = document.getElementById('rewardToast');
  toast.textContent = '🎉 获得奖励: ' + text;
  toast.classList.add('show');
  setTimeout(function() { toast.classList.remove('show'); }, 3000);
}

function showSummary() {
  launchConfetti();
  document.getElementById('summary').classList.add('show');
  setTimeout(function() {
    document.getElementById('summary').scrollIntoView({ behavior: 'smooth' });
  }, 300);
  document.getElementById('summaryScore').textContent = gameState.totalScore;
  var h = '';
  for (var i = 0; i < gameState.rewards.length; i++) {
    var r = gameState.rewards[i];
    h += '<span class="reward-item">' + r.icon + ' ' + r.name + '</span>';
  }
  document.getElementById('summaryRewards').innerHTML = h;
}

function launchConfetti() {
  var container = document.getElementById('confettiContainer');
  var colors = ['#ff6b6b','#feca57','#2ecc71','#3498db','#e74c3c','#9b59b6','#f39c12'];
  for (var i = 0; i < 60; i++) {
    var c = document.createElement('div');
    c.className = 'confetti';
    c.style.left = Math.random() * 100 + '%';
    c.style.animationDelay = Math.random() * 2 + 's';
    c.style.animationDuration = (2 + Math.random() * 2) + 's';
    c.style.background = colors[Math.floor(Math.random() * colors.length)];
    c.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
    c.style.width = (6 + Math.random() * 8) + 'px';
    c.style.height = (6 + Math.random() * 8) + 'px';
    container.appendChild(c);
  }
  setTimeout(function() { container.innerHTML = ''; }, 4000);
}

function resetGame() {
  NODES = JSON.parse(JSON.stringify(NODES_ORIGINAL));
  gameState = { completedCount: 0, totalScore: 0, rewards: [], currentNode: 0 };
  document.getElementById('summary').classList.remove('show');
  document.getElementById('redirectOverlay').classList.remove('show');
  document.getElementById('confettiContainer').innerHTML = '';
  renderAll();
  // 重置后平滑滚回页面顶部，让用户从第一章重新开始
  window.scrollTo({ top: 0, behavior: 'smooth' });
}
</script>
</body>
</html>


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal:  | SkillSpector:  ()