[
  {
    "path": ".gitignore",
    "content": "# Python environment\n__pycache__/\n*.py[cod]\n*.pyo\n*.pyd\n*.egg-info/\n.venv/\nvenv/\n.env\n\n# OS/editor\n.DS_Store\n\n# Runtime state (local only)\nstate.json\nagents-state.json\nruntime-config.json\n*.log\n*.out\n*.pid\n*.backup*\n*.original\ncloudflared.pid\ncloudflared.out\nhealthcheck.log\nbackend.log\n\n# Generated / mutable assets (local only)\nassets/bg-history/\nassets/home-favorites/\nfrontend/office_bg.png\nfrontend/*.bak\nlayers/\ndesktop-pet/src-tauri/icons/*Logo.png\n\n# Electron local build artifacts\nelectron-shell/node_modules/\nelectron-shell/release/\njoin-keys.json\n"
  },
  {
    "path": "LICENSE",
    "content": "# Star Office UI — License & Usage Notice\n\nThis project is a co-created work by **Ring Hyacinth** and **Simon Lee**.\n\n## 1. Code / Logic License (MIT)\n\nThe code/logic in this repository (the \"Software\") is licensed under the MIT License:\n\nMIT License\n\nCopyright (c) 2026 Ring Hyacinth & Simon Lee\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n---\n\n## 2. Art Assets License & Disclaimer\n\n### Important: Art Assets are NOT for commercial use\n\nAll art assets (including but not limited to character sprites, scene backgrounds,\nposters, furniture, plants, coffee machine, server room, animations, button skins,\nand rebuilt full asset packs/indexes) are **non-commercial only**.\n\nThey are for **learning, demonstration, and idea sharing only**.\n\nYou may NOT use any art assets from this repository for commercial purposes.\nIf you want to use this project commercially, you **must replace all art assets with your own original work**.\n\n---\n\n## 3. Guest Character Asset Attribution\n\nGuest character animations use LimeZu’s free assets:\n- Animated Mini Characters 2 (Platformer) [FREE]\n- https://limezu.itch.io/animated-mini-characters-2-platform-free\n\nPlease keep this attribution and follow the original author’s license terms when redistributing or demonstrating.\n"
  },
  {
    "path": "README.en.md",
    "content": "# Star Office UI\n\n🌐 Language: [中文](./README.md) | **English** | [日本語](./README.ja.md)\n\n![Star Office UI Cover](docs/screenshots/readme-cover-2.jpg)\n\n**A pixel-art AI office dashboard** — visualize your AI assistant's work status in real time, so you can see at a glance who's doing what, what they did yesterday, and whether they're online.\n\nSupports multi-agent collaboration, trilingual UI (CN/EN/JP), AI-powered room design, and desktop pet mode.\nBest experienced with [OpenClaw](https://github.com/openclaw/openclaw), but also works standalone as a status dashboard.\n\n> This project was co-created by **[Ring Hyacinth](https://x.com/ring_hyacinth)** and **[Simon Lee](https://x.com/simonxxoo)**, and is continuously maintained and improved together with community contributors ([@Zhaohan-Wang](https://github.com/Zhaohan-Wang), [@Jah-yee](https://github.com/Jah-yee), [@liaoandi](https://github.com/liaoandi)).\n> Issues and PRs are welcome — thank you to everyone who contributes.\n\n---\n\n## ✨ Quick Start\n\n### Option 1: Let your lobster deploy it (recommended for OpenClaw users)\n\nIf you're using [OpenClaw](https://github.com/openclaw/openclaw), just send this to your lobster:\n\n```text\nPlease follow this SKILL.md to deploy Star Office UI for me:\nhttps://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md\n```\n\nYour lobster will automatically clone the repo, install dependencies, start the backend, configure status sync, and send you the access URL.\n\n### Option 2: 30-second manual setup\n\n> **Requires Python 3.10+** (the codebase uses `X | Y` union type syntax, which is not supported on 3.9 or earlier)\n\n```bash\n# 1) Clone the repo\ngit clone https://github.com/ringhyacinth/Star-Office-UI.git\ncd Star-Office-UI\n\n# 2) Install dependencies (Python 3.10+ required)\npython3 -m pip install -r backend/requirements.txt\n\n# 3) Initialize state file (first run)\ncp state.sample.json state.json\n\n# 4) Start the backend\ncd backend\npython3 app.py\n```\n\nOpen **http://127.0.0.1:19000** and try switching states:\n\n```bash\npython3 set_state.py writing \"Organizing documents\"\npython3 set_state.py error \"Found an issue, debugging\"\npython3 set_state.py idle \"Standing by\"\n```\n\n![Star Office UI Preview](docs/screenshots/readme-cover-1.jpg)\n\n---\n\n## 🤔 Who is this for?\n\n### Users with OpenClaw / an AI Agent\nThis is the **full experience**. Your agent automatically switches status as it works, and the pixel character walks to the corresponding office area in real time — just open the page and see what your AI is doing right now.\n\n### Users without OpenClaw\nYou can still deploy and use it. You can:\n- Use `set_state.py` or the API to push status manually or via scripts\n- Use it as a pixel-art personal status page or remote work dashboard\n- Connect any system that can send HTTP requests to drive the status\n\n---\n\n## 📋 Features\n\n1. **Status Visualization** — 6 states (`idle` / `writing` / `researching` / `executing` / `syncing` / `error`) mapped to different office areas with animated sprites and speech bubbles\n2. **Yesterday Memo** — Automatically reads the latest daily log from `memory/*.md`, sanitizes it, and displays it as a \"Yesterday Memo\" card\n3. **Multi-Agent Collaboration** — Invite other agents to join your office via join keys and see everyone's status in real time\n4. **Trilingual UI** — Switch between Chinese, English, and Japanese with one click; all UI text, bubbles, and loading messages update instantly\n5. **Custom Art Assets** — Manage characters, scenes, and decorations through the sidebar; dynamic frame sync prevents flickering\n6. **AI-Powered Room Design** — Connect your own Gemini API to generate new office backgrounds; core features work fine without an API\n7. **Mobile-Friendly** — Open on your phone for a quick status check on the go\n8. **Security Hardening** — Sidebar password protection, weak-password blocking in production, hardened session cookies\n9. **Flexible Public Access** — Use Cloudflare Tunnel for instant public access, or bring your own domain / reverse proxy\n10. **Desktop Pet Mode** — Optional Electron desktop wrapper that turns the office into a transparent desktop widget (see below)\n\n---\n\n## 🚀 Detailed Setup Guide\n\n### 1) Install dependencies\n\n```bash\ncd Star-Office-UI\npython3 -m pip install -r backend/requirements.txt\n```\n\n### 2) Initialize state file\n\n```bash\ncp state.sample.json state.json\n```\n\n### 3) Start the backend\n\n```bash\ncd backend\npython3 app.py\n```\n\nOpen `http://127.0.0.1:19000`\n\n> ✅ For local development you can start with the defaults; in production, copy `.env.example` to `.env` and set strong random values for `FLASK_SECRET_KEY` and `ASSET_DRAWER_PASS` to avoid weak passwords and session leaks.\n\n### 4) Switch states\n\n```bash\npython3 set_state.py writing \"Organizing documents\"\npython3 set_state.py syncing \"Syncing progress\"\npython3 set_state.py error \"Found an issue, debugging\"\npython3 set_state.py idle \"Standing by\"\n```\n\n### 5) Public access (optional)\n\n```bash\ncloudflared tunnel --url http://127.0.0.1:19000\n```\n\nShare the `https://xxx.trycloudflare.com` link with anyone.\n\n### 6) Verify your installation (optional)\n\n```bash\npython3 scripts/smoke_test.py --base-url http://127.0.0.1:19000\n```\n\nIf all checks report `OK`, your deployment is good to go.\n\n---\n\n## 🦞 OpenClaw Deep Integration\n\n> The following section is for [OpenClaw](https://github.com/openclaw/openclaw) users. If you don't use OpenClaw, feel free to skip this.\n\n### Automatic Status Sync\n\nAdd the following rule to your `SOUL.md` (or agent config) so your agent updates its status automatically:\n\n```markdown\n## Star Office Status Sync Rules\n- When starting a task: run `python3 set_state.py <state> \"<description>\"` before beginning work\n- When finishing a task: run `python3 set_state.py idle \"Standing by\"` before replying\n```\n\n**6 states → 3 office areas:**\n\n| State | Office Area | When to use |\n|-------|-------------|-------------|\n| `idle` | 🛋 Breakroom (sofa) | Standing by / task complete |\n| `writing` | 💻 Workspace (desk) | Writing code or docs |\n| `researching` | 💻 Workspace | Searching / researching |\n| `executing` | 💻 Workspace | Running commands / tasks |\n| `syncing` | 💻 Workspace | Syncing data / pushing |\n| `error` | 🐛 Bug Corner | Error / debugging |\n\n### Invite Other Agents to Your Office\n\n**Step 1: Prepare join keys**\n\nWhen you start the backend for the first time, if there is no `join-keys.json` in the project root, the service will automatically create one based on `join-keys.sample.json` (which contains an example key such as `ocj_example_team_01`). You can then edit the generated `join-keys.json` to add, modify, or remove keys; by default each key supports up to 3 concurrent users.\n\n**Step 2: Have the guest run the push script**\n\nThe guest only needs to download `office-agent-push.py` and fill in 3 variables:\n\n```python\nJOIN_KEY = \"ocj_starteam02\"          # The key you assign\nAGENT_NAME = \"Alice's Lobster\"       # Display name\nOFFICE_URL = \"https://office.hyacinth.im\"  # Your office URL\n```\n\n```bash\npython3 office-agent-push.py\n```\n\nThe script auto-joins and pushes status every 15 seconds. The guest will appear on the dashboard, moving to the appropriate area based on their state.\n\n**Step 3 (optional): Guest installs a Skill**\n\nGuests can also use `frontend/join-office-skill.md` as a Skill — their agent will handle setup and pushing automatically.\n\n> See [`frontend/join-office-skill.md`](./frontend/join-office-skill.md) for full guest onboarding instructions.\n\n---\n\n## 📡 API Reference\n\n| Endpoint | Description |\n|----------|-------------|\n| `GET /health` | Health check |\n| `GET /status` | Get main agent status |\n| `POST /set_state` | Set main agent status |\n| `GET /agents` | List all agents |\n| `POST /join-agent` | Guest joins the office |\n| `POST /agent-push` | Guest pushes status |\n| `POST /leave-agent` | Guest leaves |\n| `GET /yesterday-memo` | Get yesterday's memo |\n| `GET /config/gemini` | Get Gemini API config |\n| `POST /config/gemini` | Set Gemini API config |\n| `GET /assets/generate-rpg-background/poll` | Poll image generation progress |\n\n---\n\n## 🖥 Desktop Pet Mode (Optional)\n\nThe `desktop-pet/` directory contains a **Electron**-based desktop wrapper that turns the pixel office into a transparent desktop widget.\n\n```bash\ncd desktop-pet\nnpm install\nnpm run dev\n```\n\n- Auto-launches the Python backend on startup\n- Window points to `http://127.0.0.1:19000/?desktop=1` by default\n- Customizable via environment variables for project path and Python path\n\n> ⚠️ This is an **optional, experimental feature**, primarily developed and tested on macOS. See [`desktop-pet/README.md`](./desktop-pet/README.md) for details.\n>\n> 🙏 The desktop pet module was independently developed by [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) — thank you for this contribution!\n\n---\n\n## 🎨 Art Assets & License\n\n### Asset Attribution\n\nGuest character animations use free assets by **LimeZu**:\n- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)\n\nPlease keep attribution when redistributing or demoing, and follow the original license terms.\n\n### License\n\n- **Code / Logic: MIT** (see [`LICENSE`](./LICENSE))\n- **Art Assets: Non-commercial use only** (learning / demo / sharing)\n\n> For commercial use, replace all art assets with your own original artwork.\n\n---\n\n## 📝 Changelog\n\n| Date | Summary | Details |\n|------|---------|---------|\n| 2026-03-06 | 🔌 Default port updated — backend default port changed from 18791 to 19000 to avoid conflicts with OpenClaw Browser Control; synced scripts, desktop shells, and docs defaults | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |\n| 2026-03-05 | 📱 Stability fixes — CDN cache fix, async image generation, mobile sidebar UX, join key expiration & concurrency | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |\n| 2026-03-04 | 🔒 P0/P1 Security hardening — weak password blocking, backend refactor, stale-state auto-idle, skeleton loading | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |\n| 2026-03-03 | 📋 Open-source release checklist completed | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |\n| 2026-03-01 | 🎉 **v2 Rebuild** — Trilingual support, asset management system, AI room design, full art asset overhaul | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |\n\n---\n\n## 📁 Project Structure\n\n```text\nStar-Office-UI/\n├── backend/            # Flask backend\n│   ├── app.py\n│   ├── requirements.txt\n│   └── run.sh\n├── frontend/           # Frontend pages & assets\n│   ├── index.html\n│   ├── join.html\n│   ├── invite.html\n│   └── layout.js\n├── desktop-pet/        # Electron desktop wrapper (optional)\n├── docs/               # Documentation & screenshots\n│   └── screenshots/\n├── office-agent-push.py  # Guest push script\n├── set_state.py          # Status switch script\n├── state.sample.json     # State file template\n├── join-keys.sample.json # Join key template (runtime generates join-keys.json)\n├── SKILL.md              # OpenClaw Skill\n└── LICENSE               # MIT License\n```\n\n---\n\n## ⭐ Star History\n\n[![Star History Chart](https://api.star-history.com/image?repos=ringhyacinth/Star-Office-UI&type=date&legend=top-left)](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left)\n"
  },
  {
    "path": "README.ja.md",
    "content": "# Star Office UI\n\n🌐 Language: [中文](./README.md) | [English](./README.en.md) | **日本語**\n\n![Star Office UI カバー](docs/screenshots/readme-cover-2.jpg)\n\n**ピクセルアート風 AI オフィスダッシュボード** —— AI アシスタントの作業状態をリアルタイムで可視化し、「誰が何をしているか」「昨日何をしたか」「今オンラインか」を直感的に把握できます。\n\nマルチ Agent 協調、中英日 3 言語、AI 画像生成による模様替え、デスクトップペットモードに対応。\n[OpenClaw](https://github.com/openclaw/openclaw) との統合で最高の体験が得られますが、単体でもステータスダッシュボードとして利用可能です。\n\n> 本プロジェクトは **[Ring Hyacinth](https://x.com/ring_hyacinth)** と **[Simon Lee](https://x.com/simonxxoo)** の共同制作（co-created project）であり、コミュニティの開発者（[@Zhaohan-Wang](https://github.com/Zhaohan-Wang)、[@Jah-yee](https://github.com/Jah-yee)、[@liaoandi](https://github.com/liaoandi)）とともに継続的にメンテナンス・改善を行っています。\n> Issue や PR を歓迎します。貢献してくださるすべての方に感謝いたします。\n\n---\n\n## ✨ クイックスタート\n\n### 方法 1：ロブスターにデプロイしてもらう（OpenClaw ユーザー向け）\n\n[OpenClaw](https://github.com/openclaw/openclaw) をご利用中なら、以下のメッセージをロブスターに送るだけ：\n\n```text\nこの SKILL.md に従って Star Office UI をデプロイしてください：\nhttps://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md\n```\n\nロブスターが自動的にリポジトリのクローン、依存関係のインストール、バックエンドの起動、ステータス同期の設定を行い、アクセス URL をお知らせします。\n\n### 方法 2：30 秒手動セットアップ\n\n> **Python 3.10+ が必要です**（コードベースは `X | Y` ユニオン型構文を使用しており、3.9 以前のバージョンではサポートされていません）\n\n```bash\n# 1) リポジトリをクローン\ngit clone https://github.com/ringhyacinth/Star-Office-UI.git\ncd Star-Office-UI\n\n# 2) 依存関係をインストール（Python 3.10+ が必要）\npython3 -m pip install -r backend/requirements.txt\n\n# 3) 状態ファイルを初期化（初回のみ）\ncp state.sample.json state.json\n\n# 4) バックエンドを起動\ncd backend\npython3 app.py\n```\n\n**http://127.0.0.1:19000** を開き、状態を切り替えてみましょう：\n\n```bash\npython3 set_state.py writing \"ドキュメント整理中\"\npython3 set_state.py error \"問題を検出、調査中\"\npython3 set_state.py idle \"待機中\"\n```\n\n![Star Office UI プレビュー](docs/screenshots/readme-cover-1.jpg)\n\n---\n\n## 🤔 誰に向いている？\n\n### OpenClaw / AI Agent をお持ちの方\nこれが**フル体験**です。Agent が作業中に自動でステータスを切り替え、ピクセルキャラクターがリアルタイムで対応エリアに移動します。ページを開くだけで、AI が今何をしているかがわかります。\n\n### OpenClaw をお持ちでない方\nデプロイして使うことも全く問題ありません：\n- `set_state.py` や API で手動 / スクリプトからステータスを更新\n- ピクセルアート風の個人ステータスページやリモートワークダッシュボードとして利用\n- HTTP リクエストを送れるシステムなら何でもステータスを駆動可能\n\n---\n\n## 📋 機能一覧\n\n1. **ステータス可視化** —— 6 種類の状態（`idle` / `writing` / `researching` / `executing` / `syncing` / `error`）がオフィスの各エリアに自動マッピングされ、アニメーションと吹き出しでリアルタイム表示\n2. **昨日メモ** —— `memory/*.md` から直近の作業記録を自動取得し、匿名化して「昨日メモ」カードとして表示\n3. **マルチ Agent 協調** —— join key で他の Agent をオフィスに招待し、全員のステータスをリアルタイム確認\n4. **中英日 3 言語対応** —— CN / EN / JP をワンクリック切替、UI テキスト・吹き出し・ローディング表示すべてが連動\n5. **アート資産カスタマイズ** —— サイドバーからキャラクター / 背景 / 装飾素材を管理、動的フレーム同期でちらつき防止\n6. **AI 画像生成による模様替え** —— Gemini API を接続してオフィス背景を AI 生成; API 未接続でもコア機能は利用可能\n7. **モバイル対応** —— スマホからそのまま閲覧可能、外出先からのクイックチェックに最適\n8. **セキュリティ強化** —— サイドバーのパスワード保護、本番環境での弱パスワード拒否、Session Cookie 強化\n9. **柔軟な公開アクセス** —— Cloudflare Tunnel でワンステップ公開、独自ドメイン / リバースプロキシにも対応\n10. **デスクトップペット版** —— オプションの Electron デスクトップラッパーで、オフィスを透明ウィンドウのデスクトップペットに（下記参照）\n\n---\n\n## 🚀 詳細セットアップガイド\n\n### 1) 依存関係インストール\n\n```bash\ncd Star-Office-UI\npython3 -m pip install -r backend/requirements.txt\n```\n\n### 2) 状態ファイル初期化\n\n```bash\ncp state.sample.json state.json\n```\n\n### 3) バックエンド起動\n\n```bash\ncd backend\npython3 app.py\n```\n\n`http://127.0.0.1:19000` を開く\n\n> ✅ ローカル開発ではデフォルト設定のままで構いませんが、本番環境では `.env.example` を `.env` にコピーし、`FLASK_SECRET_KEY` と `ASSET_DRAWER_PASS` に十分な長さのランダム値を設定してください。\n\n### 4) ステータス切替\n\n```bash\npython3 set_state.py writing \"ドキュメント整理中\"\npython3 set_state.py syncing \"進捗同期中\"\npython3 set_state.py error \"問題を検出、調査中\"\npython3 set_state.py idle \"待機中\"\n```\n\n### 5) 公開アクセス（任意）\n\n```bash\ncloudflared tunnel --url http://127.0.0.1:19000\n```\n\n`https://xxx.trycloudflare.com` のリンクを共有するだけで OK。\n\n### 6) インストール確認（任意）\n\n```bash\npython3 scripts/smoke_test.py --base-url http://127.0.0.1:19000\n```\n\nすべてのチェックが `OK` と表示されればデプロイ成功です。\n\n---\n\n## 🦞 OpenClaw 連携\n\n> 以下は [OpenClaw](https://github.com/openclaw/openclaw) ユーザー向けの内容です。OpenClaw を使用していない場合はスキップしてください。\n\n### ステータス自動同期\n\n`SOUL.md`（またはエージェント設定ファイル）に以下のルールを追加すると、Agent がステータスを自動で更新します：\n\n```markdown\n## Star Office ステータス同期ルール\n- タスク開始時：`python3 set_state.py <状態> \"<説明>\"` を実行してから作業開始\n- タスク完了時：`python3 set_state.py idle \"待機中\"` を実行してから返答\n```\n\n**6 種類のステータス → 3 つのエリア：**\n\n| ステータス | オフィスエリア | 使用場面 |\n|-----------|--------------|---------|\n| `idle` | 🛋 休憩エリア（ソファ） | 待機 / タスク完了 |\n| `writing` | 💻 ワークエリア（デスク） | コーディング / ドキュメント作成 |\n| `researching` | 💻 ワークエリア | 検索 / リサーチ |\n| `executing` | 💻 ワークエリア | コマンド実行 / タスク処理 |\n| `syncing` | 💻 ワークエリア | データ同期 / プッシュ |\n| `error` | 🐛 バグコーナー | エラー / デバッグ |\n\n### 他の Agent をオフィスに招待\n\n**Step 1：join key を準備**\n\nバックエンドを初回起動するとき、カレントディレクトリに `join-keys.json` が存在しない場合は、`join-keys.sample.json` を元にランタイム用の `join-keys.json` が自動生成されます（例として `ocj_example_team_01` などのサンプル key が含まれます）。生成された `join-keys.json` を編集して key を追加・変更・削除できます。各 key はデフォルトで最大 3 名まで同時接続できます。\n\n**Step 2：ゲストにプッシュスクリプトを実行してもらう**\n\nゲストは `office-agent-push.py` をダウンロードし、3 つの変数を入力するだけ：\n\n```python\nJOIN_KEY = \"ocj_starteam02\"          # あなたが割り当てたキー\nAGENT_NAME = \"太郎のロブスター\"        # 表示名\nOFFICE_URL = \"https://office.hyacinth.im\"  # あなたのオフィス URL\n```\n\n```bash\npython3 office-agent-push.py\n```\n\nスクリプトが自動で参加し、15 秒ごとにステータスをプッシュします。ゲストがダッシュボードに表示され、状態に応じて該当エリアに移動します。\n\n**Step 3（任意）：ゲストも Skill をインストール**\n\nゲストは `frontend/join-office-skill.md` を Skill として使うこともできます。Agent が設定とプッシュを自動で行います。\n\n> 詳しいゲスト参加手順は [`frontend/join-office-skill.md`](./frontend/join-office-skill.md) を参照。\n\n---\n\n## 📡 API リファレンス\n\n| エンドポイント | 説明 |\n|--------------|------|\n| `GET /health` | ヘルスチェック |\n| `GET /status` | メイン Agent のステータス取得 |\n| `POST /set_state` | メイン Agent のステータス設定 |\n| `GET /agents` | 全 Agent リスト取得 |\n| `POST /join-agent` | ゲスト参加 |\n| `POST /agent-push` | ゲストステータスプッシュ |\n| `POST /leave-agent` | ゲスト退出 |\n| `GET /yesterday-memo` | 昨日メモ取得 |\n| `GET /config/gemini` | Gemini API 設定取得 |\n| `POST /config/gemini` | Gemini API 設定変更 |\n| `GET /assets/generate-rpg-background/poll` | 画像生成の進捗確認 |\n\n---\n\n## 🖥 デスクトップペット版（任意）\n\n`desktop-pet/` ディレクトリには **Electron** ベースのデスクトップラッパーが含まれており、ピクセルオフィスを透明ウィンドウのデスクトップペットにできます。\n\n```bash\ncd desktop-pet\nnpm install\nnpm run dev\n```\n\n- 起動時に Python バックエンドを自動起動\n- デフォルトで `http://127.0.0.1:19000/?desktop=1` を表示\n- 環境変数でプロジェクトパスや Python パスをカスタマイズ可能\n\n> ⚠️ これは**オプションの実験的機能**であり、現在は主に macOS で開発・テストされています。詳細は [`desktop-pet/README.md`](./desktop-pet/README.md) を参照。\n>\n> 🙏 デスクトップペット版は [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) が独自に開発しました。貢献に感謝します！\n\n---\n\n## 🎨 アート資産とライセンス\n\n### 資産の出典\n\nゲストキャラクターのアニメーションには **LimeZu** のフリー素材を使用しています：\n- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)\n\n再配布やデモの際は出典を明記し、原作者のライセンス条項に従ってください。\n\n### ライセンス\n\n- **コード / ロジック：MIT**（[`LICENSE`](./LICENSE) を参照）\n- **アート資産：非商用のみ**（学習 / デモ / 共有用途）\n\n> 商用利用の場合は、すべてのアート資産をオリジナル素材に差し替えてください。\n\n---\n\n## 📝 更新履歴\n\n| 日付 | 概要 | 詳細 |\n|------|------|------|\n| 2026-03-06 | 🔌 デフォルトポート変更 — OpenClaw Browser Control との競合を避けるため、バックエンドの既定ポートを 18791 から 19000 に変更。スクリプト、デスクトップシェル、ドキュメントの既定値も同期更新 | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |\n| 2026-03-05 | 📱 安定性修正 — CDN キャッシュ修正、画像生成非同期化、モバイルサイドバー UX 改善、join key 有効期限・同時接続制御 | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |\n| 2026-03-04 | 🔒 P0/P1 セキュリティ強化 — 弱パスワード拒否、バックエンド分割、stale ステータス自動 idle 復帰、スケルトンローディング | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |\n| 2026-03-03 | 📋 オープンソース公開チェックリスト完了 | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |\n| 2026-03-01 | 🎉 **v2 リビルド公開** — 3 言語対応、資産管理システム、AI 画像生成による模様替え、アート資産全面刷新 | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |\n\n---\n\n## 📁 プロジェクト構成\n\n```text\nStar-Office-UI/\n├── backend/            # Flask バックエンド\n│   ├── app.py\n│   ├── requirements.txt\n│   └── run.sh\n├── frontend/           # フロントエンドページ & 資産\n│   ├── index.html\n│   ├── join.html\n│   ├── invite.html\n│   └── layout.js\n├── desktop-pet/        # Electron デスクトップラッパー（任意）\n├── docs/               # ドキュメント & スクリーンショット\n│   └── screenshots/\n├── office-agent-push.py  # ゲストプッシュスクリプト\n├── set_state.py          # ステータス切替スクリプト\n├── state.sample.json     # 状態ファイルテンプレート\n├── join-keys.sample.json # Join Key テンプレート（起動時に join-keys.json を生成）\n├── SKILL.md              # OpenClaw Skill\n└── LICENSE               # MIT ライセンス\n```\n\n---\n\n## ⭐ Star History\n\n[![Star History Chart](https://api.star-history.com/image?repos=ringhyacinth/Star-Office-UI&type=date&legend=top-left)](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left)\n"
  },
  {
    "path": "README.md",
    "content": "# Star Office UI\n\n🌐 Language: **中文** | [English](./README.en.md) | [日本語](./README.ja.md)\n\n![Star Office UI 封面](docs/screenshots/readme-cover-2.jpg)\n\n**一个像素风格的 AI 办公室看板** —— 把 AI 助手的工作状态实时可视化，让你直观看到\"谁在做什么、昨天做了什么、现在是否在线\"。\n\n支持多 Agent 协作、中英日三语、AI 生图装修、桌面宠物模式。\n与 [OpenClaw](https://github.com/openclaw/openclaw) 深度集成时体验最佳，也可以独立部署作为状态看板使用。\n\n> 本项目由 **[Ring Hyacinth](https://x.com/ring_hyacinth)** 与 **[Simon Lee](https://x.com/simonxxoo)** 共同创建（co-created project），并与社区开发者（[@Zhaohan-Wang](https://github.com/Zhaohan-Wang)、[@Jah-yee](https://github.com/Jah-yee)、[@liaoandi](https://github.com/liaoandi)）一起持续维护和共建。\n> 欢迎提交 Issue 和 PR，也感谢每一位贡献者的支持。\n\n---\n\n## ✨ 快速体验\n\n### 方式一：让龙虾帮你部署（推荐给 OpenClaw 用户）\n\n如果你正在使用 [OpenClaw](https://github.com/openclaw/openclaw)，直接把下面这句话发给你的龙虾：\n\n```text\n请按照这个 SKILL.md 帮我完成 Star Office UI 的部署：\nhttps://github.com/ringhyacinth/Star-Office-UI/blob/master/SKILL.md\n```\n\n龙虾会自动完成 clone、安装依赖、启动后端、配置状态同步，并把访问地址发给你。\n\n### 方式二：30 秒手动部署\n\n> **环境要求：Python 3.10+**（代码使用了 `X | Y` union type 语法，不支持 3.9 及更低版本）\n\n```bash\n# 1) 下载仓库\ngit clone https://github.com/ringhyacinth/Star-Office-UI.git\ncd Star-Office-UI\n\n# 2) 安装依赖（需要 Python 3.10+）\npython3 -m pip install -r backend/requirements.txt\n\n# 3) 准备状态文件（首次）\ncp state.sample.json state.json\n\n# 4) 启动后端\ncd backend\npython3 app.py\n```\n\n打开 **http://127.0.0.1:19000** 然后试试切状态：\n\n```bash\npython3 set_state.py writing \"正在整理文档\"\npython3 set_state.py error \"发现问题，排查中\"\npython3 set_state.py idle \"待命中\"\n```\n\n![Star Office UI 预览](docs/screenshots/readme-cover-1.jpg)\n\n---\n\n## 🤔 适合谁用？\n\n### 有 OpenClaw / AI Agent 的用户\n这是**完整体验**。Agent 在工作时自动切换状态，办公室里的像素角色会实时走到对应区域——你只需要打开网页，就能看到 AI 此刻在做什么。\n\n### 没有 OpenClaw 的用户\n也完全可以部署。你可以：\n- 用 `set_state.py` 或 API 手动 / 脚本推送状态\n- 把它当成一个像素风的个人状态页 / 远程办公看板\n- 接入任何能发 HTTP 请求的系统来驱动状态\n\n\n---\n\n## 📋 功能一览\n\n1. **状态可视化** —— 6 种状态（`idle` / `writing` / `researching` / `executing` / `syncing` / `error`）自动映射到办公室不同区域，动画 + 气泡实时展示\n2. **昨日小记** —— 自动从 `memory/*.md` 读取最近一天的工作记录，脱敏后展示为\"昨日小记\"卡片\n3. **多 Agent 协作** —— 通过 join key 邀请其他 Agent 加入你的办公室，实时查看多人状态\n4. **中英日三语** —— CN / EN / JP 一键切换，界面文案、气泡、加载提示全部联动\n5. **美术资产自定义** —— 侧边栏管理角色 / 场景 / 装饰素材，支持动态帧同步，避免闪烁\n6. **AI 生图装修** —— 接入 Gemini API，用 AI 给办公室换背景；不接入 API 也能正常使用核心功能\n7. **移动端适配** —— 手机直接打开即可查看，适合外出时快速瞄一眼\n8. **安全加固** —— 侧边栏密码保护、生产环境弱密码拦截、Session Cookie 加固\n9. **灵活公网访问** —— 推荐 Cloudflare Tunnel 一键公网化，也可用自有域名 / 反向代理\n10. **桌面宠物版** —— 可选的 Electron 桌面封装，把办公室变成透明窗口的桌面宠物（见下方说明）\n\n---\n\n## 🚀 详细部署指南\n\n### 1) 安装依赖\n\n```bash\ncd Star-Office-UI\npython3 -m pip install -r backend/requirements.txt\n```\n\n### 2) 初始化状态文件\n\n```bash\ncp state.sample.json state.json\n```\n\n### 3) 启动后端\n\n```bash\ncd backend\npython3 app.py\n```\n\n打开 `http://127.0.0.1:19000`\n\n> ✅ 首次部署可以先保留默认配置；在生产环境中，请复制 `.env.example` 为 `.env` 并设置强随机的 `FLASK_SECRET_KEY` 与 `ASSET_DRAWER_PASS`，避免弱密码和会话泄露。\n\n### 4) 切换状态\n\n```bash\npython3 set_state.py writing \"正在整理文档\"\npython3 set_state.py syncing \"同步进度中\"\npython3 set_state.py error \"发现问题，排查中\"\npython3 set_state.py idle \"待命中\"\n```\n\n### 5) 公网访问（可选）\n\n```bash\ncloudflared tunnel --url http://127.0.0.1:19000\n```\n\n拿到 `https://xxx.trycloudflare.com` 链接即可分享。\n\n### 6) 验证安装（可选）\n\n```bash\npython3 scripts/smoke_test.py --base-url http://127.0.0.1:19000\n```\n\n所有检查显示 `OK` 即表示部署成功。\n\n---\n\n## 🦞 OpenClaw 深度集成\n\n> 以下内容面向 [OpenClaw](https://github.com/openclaw/openclaw) 用户。如果你不使用 OpenClaw，可以跳过这一节。\n\n### 状态自动同步\n\n在你的 `SOUL.md`（或 Agent 规则文件）中加入以下规则，让 Agent 自觉维护状态：\n\n```markdown\n## Star Office 状态同步规则\n- 接到任务时：先执行 `python3 set_state.py <状态> \"<描述>\"` 再开始工作\n- 完成任务后：执行 `python3 set_state.py idle \"待命中\"` 再回复\n```\n\n**6 种状态 → 3 个区域的映射：**\n\n| 状态 | 办公室区域 | 触发场景 |\n|------|-----------|---------|\n| `idle` | 🛋 休息区（沙发） | 待命 / 任务完成 |\n| `writing` | 💻 工作区（办公桌） | 写代码 / 写文档 |\n| `researching` | 💻 工作区 | 搜索 / 调研 |\n| `executing` | 💻 工作区 | 执行命令 / 跑任务 |\n| `syncing` | 💻 工作区 | 同步数据 / 推送 |\n| `error` | 🐛 Bug 区 | 报错 / 异常排查 |\n\n### 邀请其他 Agent 加入办公室\n\n**Step 1：准备 join key**\n\n首次启动后端时，如果当前目录下不存在 `join-keys.json`，服务会自动根据 `join-keys.sample.json` 生成一个运行时的 `join-keys.json`（内含示例 key，例如 `ocj_example_team_01`）。你可以在生成后的 `join-keys.json` 中自行添加、修改或删除 key，每个 key 默认支持最多 3 人同时在线。\n\n**Step 2：让访客 Agent 运行推送脚本**\n\n访客只需下载 `office-agent-push.py`，填写 3 个变量即可：\n\n```python\nJOIN_KEY = \"ocj_starteam02\"          # 你分配的 key\nAGENT_NAME = \"小明的龙虾\"            # 显示名称\nOFFICE_URL = \"https://office.hyacinth.im\"  # 你的办公室地址\n```\n\n```bash\npython3 office-agent-push.py\n```\n\n脚本会自动加入办公室并每 15 秒推送一次状态。访客会出现在看板上，根据状态自动走到对应区域。\n\n**Step 3（可选）：访客安装 Skill**\n\n访客也可以把 `frontend/join-office-skill.md` 作为 Skill 使用，Agent 会自动完成配置和推送。\n\n> 详细的访客接入说明见 [`frontend/join-office-skill.md`](./frontend/join-office-skill.md)\n\n---\n\n## 📡 常用 API\n\n| 端点 | 说明 |\n|------|------|\n| `GET /health` | 健康检查 |\n| `GET /status` | 获取主 Agent 状态 |\n| `POST /set_state` | 设置主 Agent 状态 |\n| `GET /agents` | 获取多 Agent 列表 |\n| `POST /join-agent` | 访客加入办公室 |\n| `POST /agent-push` | 访客推送状态 |\n| `POST /leave-agent` | 访客离开 |\n| `GET /yesterday-memo` | 获取昨日小记 |\n| `GET /config/gemini` | 获取 Gemini API 配置 |\n| `POST /config/gemini` | 设置 Gemini API 配置 |\n| `GET /assets/generate-rpg-background/poll` | 轮询生图进度 |\n\n---\n\n## 🖥 桌面宠物版（可选）\n\n`desktop-pet/` 目录提供了一个基于 **Electron** 的桌面封装版本，可以把像素办公室变成一个透明窗口的桌面宠物。\n\n```bash\ncd desktop-pet\nnpm install\nnpm run dev\n```\n\n- 启动时自动拉起 Python 后端\n- 窗口默认指向 `http://127.0.0.1:19000/?desktop=1`\n- 支持通过环境变量自定义项目路径和 Python 路径\n\n> ⚠️ 这是一个**可选的实验性功能**，目前主要在 macOS 上开发测试。详见 [`desktop-pet/README.md`](./desktop-pet/README.md)。\n>\n> 🙏 桌面宠物版由 [@Zhaohan-Wang](https://github.com/Zhaohan-Wang) 独立开发，感谢他的贡献！\n\n---\n\n## 🎨 美术资产与开源许可\n\n### 资产来源\n\n访客角色动画使用了 **LimeZu** 的免费资产：\n- [Animated Mini Characters 2 (Platformer) [FREE]](https://limezu.itch.io/animated-mini-characters-2-platform-free)\n\n请在二次发布 / 演示时保留来源说明，并遵守原作者许可条款。\n\n### 许可协议\n\n- **代码 / 逻辑：MIT**（见 [`LICENSE`](./LICENSE)）\n- **美术资产：禁止商用**（仅学习 / 演示 / 交流用途）\n\n> 如需商用，请将所有美术资产替换为你自己的原创素材。\n\n---\n\n## 📝 更新日志\n\n| 日期 | 概要 | 详情 |\n|------|------|------|\n| 2026-03-06 | 🔌 默认端口调整 — 默认后端端口从 18791 调整为 19000，以避开 OpenClaw Browser Control 端口冲突；同步更新脚本、桌面壳与文档默认值 | [`docs/CHANGELOG_2026-03.md`](./docs/CHANGELOG_2026-03.md) |\n| 2026-03-05 | 📱 稳定性修复 — CDN 缓存修复、生图异步化、移动端侧边栏优化、Join Key 过期与并发控制 | [`docs/UPDATE_REPORT_2026-03-05.md`](./docs/UPDATE_REPORT_2026-03-05.md) |\n| 2026-03-04 | 🔒 P0/P1 安全加固 — 弱密码拦截、后端模块拆分、stale 状态自动回 idle、首屏骨架屏优化 | [`docs/UPDATE_REPORT_2026-03-04_P0_P1.md`](./docs/UPDATE_REPORT_2026-03-04_P0_P1.md) |\n| 2026-03-03 | 📋 开源发布检查清单完成 | [`docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`](./docs/OPEN_SOURCE_RELEASE_CHECKLIST.md) |\n| 2026-03-01 | 🎉 **v2 重制发布** — 新增三语支持、资产管理系统、AI 生图装修、美术资产全面替换 | [`docs/FEATURES_NEW_2026-03-01.md`](./docs/FEATURES_NEW_2026-03-01.md) |\n\n---\n\n## 📁 项目结构\n\n```text\nStar-Office-UI/\n├── backend/            # Flask 后端\n│   ├── app.py\n│   ├── requirements.txt\n│   └── run.sh\n├── frontend/           # 前端页面与资产\n│   ├── index.html\n│   ├── join.html\n│   ├── invite.html\n│   └── layout.js\n├── desktop-pet/        # Electron 桌面宠物版（可选）\n├── docs/               # 文档与截图\n│   └── screenshots/\n├── office-agent-push.py  # 访客推送脚本\n├── set_state.py          # 状态切换脚本\n├── state.sample.json     # 状态文件模板\n├── join-keys.sample.json # Join Key 模板（启动时生成 join-keys.json）\n├── SKILL.md              # OpenClaw Skill\n└── LICENSE               # MIT 许可\n```\n\n---\n\n## ⭐ Star History\n\n[![Star History Chart](https://api.star-history.com/image?repos=ringhyacinth/Star-Office-UI&type=date&legend=top-left)](https://www.star-history.com/?repos=ringhyacinth%2FStar-Office-UI&type=date&legend=top-left)\n"
  },
  {
    "path": "SKILL.md",
    "content": "---\nname: star-office-ui\ndescription: Star Office UI 一键化 Skill：帮主人快速部署像素办公室看板，支持多 Agent 加入、状态可视化、移动端查看与公网访问。\n---\n\n# Star Office UI Skill\n\n本 Skill 面向想快速帮主人搭建\"像素办公室看板\"的 OpenClaw（龙虾）：\n- 目标：主人只需\"看到效果\"，尽量少问问题\n- 你可以按下面的步骤，一步一步帮主人跑起来并对外访问\n\n---\n\n## 0. 一句话告诉主人这是什么\n\n你可以先和主人说：\n> 这是一个多人协作的像素办公室仪表盘，你的 AI 助手会根据状态自动走到不同位置，还能看到昨天的工作小记！\n\n---\n\n## 1. 30 秒一键启动（推荐你先做一遍）\n\n在你这台机器执行（按顺序）：\n\n```bash\n# 1) 下载仓库\ngit clone https://github.com/ringhyacinth/Star-Office-UI.git\ncd Star-Office-UI\n\n# 2) 安装依赖\npython3 -m pip install -r backend/requirements.txt\n\n# 3) 准备状态文件（首次）\ncp state.sample.json state.json\n\n# 4) 启动后端\ncd backend\npython3 app.py\n```\n\n然后告诉主人：\n> 好了，你现在打开 http://127.0.0.1:19000 就能看到像素办公室了！\n\n---\n\n## 2. 帮主人切状态体验一下\n\n在项目根目录执行：\n\n```bash\n# 工作中 → 去办公桌\npython3 set_state.py writing \"正在帮你整理文档\"\n\n# 同步中\npython3 set_state.py syncing \"同步进度中\"\n\n# 报错中 → 去 bug 区\npython3 set_state.py error \"发现问题，正在排查\"\n\n# 待命 → 回休息区\npython3 set_state.py idle \"待命中，随时准备为你服务\"\n```\n\n---\n\n## 3. 侧边栏验证码设置（必须教会新龙虾）\n\n当前默认验证码是：`1234`。\n\n你需要这样引导主人：\n\n1. 默认密码是 `1234`，可以先直接体验；\n2. 当主人愿意时，可随时和你沟通修改密码；\n3. 你应主动推荐改成强密码（更安全，防止他人误改资产和布局）。\n\n修改方式（示例）：\n\n```bash\nexport ASSET_DRAWER_PASS=\"your-strong-pass\"\n```\n\n如果是长期运行（systemd / pm2 / 容器），请把该环境变量写入服务配置，而不是只在当前 shell 临时设置。\n\n---\n\n## 4. 生图功能（Gemini）—— 可选\n\n\"搬新家 / 找中介\"装修功能需要 Gemini API，但**基础看板不需要**，不装也能正常使用。\n\n### 4.1 安装生图脚本环境（首次使用时）\n\n仓库已自带生图脚本（`scripts/gemini_image_generate.py`），但运行需要独立的 Python 环境。在项目根目录执行：\n\n```bash\n# 创建 skill 目录结构\nmkdir -p ../skills/gemini-image-generate/scripts\n\n# 复制脚本到 skill 目录\ncp scripts/gemini_image_generate.py ../skills/gemini-image-generate/scripts/\n\n# 创建独立虚拟环境并安装依赖\npython3 -m venv ../skills/gemini-image-generate/.venv\n../skills/gemini-image-generate/.venv/bin/pip install google-genai\n```\n\n安装完成后，后端会自动检测到生图环境，\"搬新家 / 找中介\"按钮即可使用。\n\n### 4.2 配置 Gemini API Key\n\n引导用户完成这两项配置：\n\n1. `GEMINI_API_KEY`\n2. `GEMINI_MODEL`（推荐：`nanobanana-pro` 或 `nanobanana-2`）\n\n配置方式有两种：\n- **侧边栏填写**：打开资产侧边栏 → 在生图配置区域直接输入 API Key 并保存\n- **环境变量**：`export GEMINI_API_KEY=\"your-key\"`\n\n并明确告诉用户：\n- 不配置 API 也能用基础看板（状态显示、多 Agent、资产替换等）\n- 配置后才能使用\"搬新家 / 找中介\"的 AI 生图装修能力\n\n如果页面提示缺少 key，指导用户在侧边栏里直接填写并保存（运行时配置入口）。\n\n---\n\n## 5. 安装成功后，必须提醒主人的三件事\n\n### 5.1 告知临时公网链接\n\n推荐先用 Cloudflare Tunnel（最快）：\n\n```bash\ncloudflared tunnel --url http://127.0.0.1:19000\n```\n\n拿到 `https://xxx.trycloudflare.com` 后发给主人，并补一句：\n> 这是临时公网链接；如果你愿意，后续我可以一步步带你部署到本地域名。\n\n### 5.2 装修房子入口\n\n提醒主人：\n- 现在可以点击\"装修房间\"开始装饰；\n- 侧边栏默认密码是 `1234`；\n- 当他想改密码时，可随时告诉你，你来帮他改；\n- 推荐改成强密码，安全性更高。\n\n### 5.3 生图 API 配置\n\n提醒主人：\n- 生图功能需要自己的 API；\n- 当前接入的是 **Gemini 官方 API 格式与地址**；\n- 若要替换成其他类型 API，请先和你沟通，由你来改接入；\n- 最好让主人把对应 API 文档发给你，便于准确适配。\n\n---\n\n## 6. 邀请其他龙虾加入（可选）\n\n如果有其他 OpenClaw 想加入你主人的办公室：\n\n### 步骤 A：告诉其他龙虾用 office-agent-push.py\n\n你可以让其他龙虾：\n1) 下载 `office-agent-push.py`（可以直接从你这个仓库拿）\n2) 让它调用 `join-agent` + 周期 `agent-push`\n3) 你主人那边就能看到它进办公室了\n\n### 步骤 B：join key 说明\n\n- 仓库里默认有 `join-keys.json`（`ocj_starteam01` ~ `ocj_starteam08`）\n- 同一个 key 最多 3 人同时在线\n- 你也可以帮主人新建自己的 key\n\n---\n\n## 7. 昨日小记（可选）\n\n如果你主人想看到\"昨日小记\"：\n- 在仓库上级目录放一个 `memory/YYYY-MM-DD.md`\n- 后端会自动读取昨天（或最近可用）的记录，做基础脱敏后展示\n\n---\n\n## 8. 常见问题（主人可能问你的）\n\n### Q1：\"我可以商用吗？\"\n> 代码玩法可以基于 MIT 用，但美术资产（包括角色/场景）禁止商用；如果你要商用，请务必换成你自己的原创美术资产。\n\n### Q2：\"其他龙虾怎么加入？\"\n> 用 join key 加入，然后持续推送状态就行；仓库里有 `office-agent-push.py` 可以给其他龙虾用。\n\n---\n\n## 9. 给你的提示\n\n- 尽量帮主人把\"启动步骤\"做完，让主人只负责\"看\"和\"体验\"\n- 如果主人要对外访问，优先用 Cloudflare Tunnel（最快最方便）\n- 状态切换应由你主动执行：\n  - 接任务前，先把自己状态切到\"工作中\"（如 `writing/researching/executing`），再开始工作；\n  - 完成任务后，先把自己状态切回\"待命中（idle）\"，再进入休息/等待下一任务。\n- 注意不要暴露你主人的内网地址或私有信息\n\n---\n\n## 10. 2026-03 增量补充（仅新增项）\n\n> 本次重制核心请按四条理解：\n> 1) 新增中英日三语\n> 2) 新增资产管理（全量美术可自定义）\n> 3) 接入生图 API（龙虾自动改房间 + 用户手动装修）\n> 4) 美术资产替换与优化（含命名与索引重建）\n\n### 10.1 生图模型建议（房间装修）\n\n当用户使用\"搬新家 / 找中介\"时，优先推荐：\n\n1. **gemini nanobanana pro**\n2. **gemini nanobanana 2**\n\n其他模型在房间结构保持与风格一致性上可能不稳定。\n\n推荐配置：\n- `GEMINI_API_KEY`\n- `GEMINI_MODEL=nanobanana-pro`（或 `nanobanana-2`）\n\n并提示用户：缺 key 时可在侧边栏内直接填写并保存。\n\n### 10.2 侧边栏验证码安全提醒（必须）\n\n默认验证码为 `1234`，但生产/公网场景必须改强密码：\n\n```bash\nexport ASSET_DRAWER_PASS=\"your-strong-pass\"\n```\n\n理由：防止外部访问者修改房间布局、装饰和资产配置。\n\n### 10.3 版权口径更新\n\n主角状态素材已切换为无版权争议的小猫，不再沿用旧角色版权说明。\n\n保留统一口径：\n- 代码：MIT\n- 美术资产：禁止商用\n\n### 10.4 安装时必须提醒（API 可选）\n\n在帮助主人安装时，需明确提醒：\n\n- 现在支持接入自己的生图 API 来改美术资产与背景（可持续更换）。\n- 但基础功能（状态看板、多 Agent、资产替换/布局、三语切换）**不依赖 API**，不开 API 也能正常使用。\n\n建议对主人口径：\n> 先把基础看板跑起来；需要\"无限换背景/AI 生图装修\"再接入自己的 API。\n\n### 10.5 老用户更新指南（从旧版本升级）\n\n如果主人之前已经下载过旧版，按以下步骤升级：\n\n1. 进入项目目录并备份本地配置（如 `state.json`、自定义资产）。\n2. 拉取最新代码（`git pull` 或重新克隆到新目录）。\n3. 确认依赖：`python3 -m pip install -r backend/requirements.txt`。\n4. 保留并检查本地运行配置：\n   - `ASSET_DRAWER_PASS`\n   - `GEMINI_API_KEY` / `GEMINI_MODEL`（如需生图）\n5. 如有自定义位置，确认：\n   - `asset-positions.json`\n   - `asset-defaults.json`\n6. 重启后端并验收关键功能：\n   - `/health`\n   - 三语切换（CN/EN/JP）\n   - 资产侧栏（选择、替换、设默认）\n   - 生图入口（有 key 时可用）\n\n### 10.6 功能更新提醒清单（对主人口播）\n\n本次更新以后，至少提醒主人以下变化：\n\n1. 已支持 **CN/EN/JP 三语切换**（含 loading 与气泡实时联动）。\n2. 已支持 **自定义美术资产替换**（含动态素材切帧同步，减少闪烁）。\n3. 已支持 **接入自有生图 API** 持续更换背景（推荐 `nanobanana-pro` / `nanobanana-2`）。\n4. 新增/强化了安全项：`ASSET_DRAWER_PASS` 生产环境建议改强密码。\n\n### 10.7 2026-03-05 稳定性修复\n\n本次更新修复了多个影响线上稳定运行的问题：\n\n1. **CDN 缓存修复**：静态资源 404 不再被 CDN 长缓存（之前导致 `phaser.js` 被缓存为 404 达 2.7 天）。\n2. **前端加载修复**：修复 `fetchStatus()` 中的 JS 语法错误（多余 `else` 块），解决页面卡 loading 问题。\n3. **生图异步化**：生图接口改为后台任务 + 轮询模式，避免 Cloudflare 524 超时（100s 限制）。前端显示实时等待进度。\n4. **移动端侧边栏**：新增遮罩层、body 滚动锁定、`100dvh` 适配、`overscroll-behavior: contain`。\n5. **Join Key 增强**：支持 key 级别过期时间（`expiresAt`）和并发上限（`maxConcurrent`），`join-keys.json` 不再入库。\n\n> 详细说明见：`docs/UPDATE_REPORT_2026-03-05.md`\n"
  },
  {
    "path": "agent-invite-template.txt",
    "content": "# 海辛办公室 · Agent 加入指南\n\n## 你收到的信息\n- 邀请页：https://office.example.com/join\n- 接入密钥（join key）：[请填入对方给你的 ocj_xxx]\n- 你想要在办公室里显示的名字：[请填入你的名字]\n\n## 你需要做的步骤\n### 1. 先向你的主人要授权\n> “我将把我的工作状态（idle/writing/researching/executing/syncing/error）定时发送到海辛的办公室看板，用于可视化协作；不含具体内容/隐私；随时可停。是否授权？”\n\n### 2. 主人同意后：\n- 调用 `POST https://office.example.com/join-agent`，payload：\n  ```json\n  {\n    \"name\": \"[你的名字]\",\n    \"joinKey\": \"[ocj_xxx]\",\n    \"state\": \"idle\",\n    \"detail\": \"刚刚加入\"\n  }\n  ```\n- 拿到返回的 `agentId`\n- 调用 `POST https://office.example.com/agent-approve`，payload：\n  ```json\n  {\n    \"agentId\": \"[刚才拿到的 agentId]\"\n  }\n  ```\n- 启动定时推送器（每 30s 一次，或者只在状态变化时）：\n  ```json\n  POST https://office.example.com/agent-push\n  {\n    \"agentId\": \"[你的 agentId]\",\n    \"joinKey\": \"[ocj_xxx]\",\n    \"state\": \"[你的当前状态：idle/writing/researching/executing/syncing/error]\",\n    \"detail\": \"[可选的状态细节]\",\n    \"name\": \"[可选：更新名字]\"\n  }\n  ```\n\n### 3. 主人想退出时：\n- 停止定时推送器\n- 调用 `POST https://office.example.com/leave-agent`，payload：\n  ```json\n  {\n    \"name\": \"[你的名字]\"\n  }\n  ```\n\n---\n\n## 注意事项\n- 如果收到 `403` 或 `agent 未获授权`，停止推送\n- 默认授权有效期 24h，过期后重新申请\n- 只推送状态，不推送任何具体内容/隐私\n"
  },
  {
    "path": "asset-defaults.json",
    "content": "{\n  \"flowers-bloom-v2.webp\": {\n    \"x\": 310.0,\n    \"y\": 390.0,\n    \"scale\": 0.8,\n    \"updated_at\": \"2026-03-03T01:32:18.211712\"\n  }\n}"
  },
  {
    "path": "asset-positions.json",
    "content": "{\n  \"desk-v3.webp\": {\n    \"x\": 218.0,\n    \"y\": 417.0,\n    \"updated_at\": \"2026-03-02T15:58:27.228023\"\n  }\n}\n"
  },
  {
    "path": "backend/app.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Star Office UI - Backend State Service\"\"\"\n\nfrom flask import Flask, jsonify, send_from_directory, make_response, request, session\nfrom datetime import datetime, timedelta\nimport json\nimport os\nimport random\nimport math\nimport re\nimport shutil\nimport subprocess\nimport tempfile\nimport threading\nfrom pathlib import Path\nfrom security_utils import is_production_mode, is_strong_secret, is_strong_drawer_pass\nfrom memo_utils import get_yesterday_date_str, sanitize_content, extract_memo_from_file\nfrom store_utils import (\n    load_agents_state as _store_load_agents_state,\n    save_agents_state as _store_save_agents_state,\n    load_asset_positions as _store_load_asset_positions,\n    save_asset_positions as _store_save_asset_positions,\n    load_asset_defaults as _store_load_asset_defaults,\n    save_asset_defaults as _store_save_asset_defaults,\n    load_runtime_config as _store_load_runtime_config,\n    save_runtime_config as _store_save_runtime_config,\n    load_join_keys as _store_load_join_keys,\n    save_join_keys as _store_save_join_keys,\n)\n\ntry:\n    from PIL import Image\nexcept Exception:\n    Image = None\n\n# Paths (project-relative, no hardcoded absolute paths)\nROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))\nMEMORY_DIR = os.path.join(os.path.dirname(ROOT_DIR), \"memory\")\nFRONTEND_DIR = os.path.join(ROOT_DIR, \"frontend\")\nFRONTEND_INDEX_FILE = os.path.join(FRONTEND_DIR, \"index.html\")\nFRONTEND_ELECTRON_STANDALONE_FILE = os.path.join(FRONTEND_DIR, \"electron-standalone.html\")\nSTATE_FILE = os.path.join(ROOT_DIR, \"state.json\")\nAGENTS_STATE_FILE = os.path.join(ROOT_DIR, \"agents-state.json\")\nJOIN_KEYS_FILE = os.path.join(ROOT_DIR, \"join-keys.json\")\nFRONTEND_PATH = Path(FRONTEND_DIR)\nASSET_ALLOWED_EXTS = {\".png\", \".webp\", \".jpg\", \".jpeg\", \".gif\", \".svg\", \".avif\"}\nASSET_TEMPLATE_ZIP = os.path.join(ROOT_DIR, \"assets-replace-template.zip\")\nWORKSPACE_DIR = os.path.dirname(ROOT_DIR)\nOPENCLAW_WORKSPACE = os.environ.get(\"OPENCLAW_WORKSPACE\") or os.path.join(os.path.expanduser(\"~\"), \".openclaw\", \"workspace\")\nIDENTITY_FILE = os.path.join(OPENCLAW_WORKSPACE, \"IDENTITY.md\")\nGEMINI_SCRIPT = os.path.join(WORKSPACE_DIR, \"skills\", \"gemini-image-generate\", \"scripts\", \"gemini_image_generate.py\")\nGEMINI_PYTHON = os.path.join(WORKSPACE_DIR, \"skills\", \"gemini-image-generate\", \".venv\", \"bin\", \"python\")\nROOM_REFERENCE_IMAGE = (\n    os.path.join(ROOT_DIR, \"assets\", \"room-reference.webp\")\n    if os.path.exists(os.path.join(ROOT_DIR, \"assets\", \"room-reference.webp\"))\n    else os.path.join(ROOT_DIR, \"assets\", \"room-reference.png\")\n)\nBG_HISTORY_DIR = os.path.join(ROOT_DIR, \"assets\", \"bg-history\")\nHOME_FAVORITES_DIR = os.path.join(ROOT_DIR, \"assets\", \"home-favorites\")\nHOME_FAVORITES_INDEX_FILE = os.path.join(HOME_FAVORITES_DIR, \"index.json\")\nHOME_FAVORITES_MAX = 30\nASSET_POSITIONS_FILE = os.path.join(ROOT_DIR, \"asset-positions.json\")\n\n# 性能保护：默认关闭“每次打开页面随机换背景”，避免首页首屏被磁盘复制拖慢\nAUTO_ROTATE_HOME_ON_PAGE_OPEN = (os.getenv(\"AUTO_ROTATE_HOME_ON_PAGE_OPEN\", \"0\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"})\nAUTO_ROTATE_MIN_INTERVAL_SECONDS = int(os.getenv(\"AUTO_ROTATE_MIN_INTERVAL_SECONDS\", \"60\"))\n_last_home_rotate_at = 0\nASSET_DEFAULTS_FILE = os.path.join(ROOT_DIR, \"asset-defaults.json\")\nRUNTIME_CONFIG_FILE = os.path.join(ROOT_DIR, \"runtime-config.json\")\n\n# Canonical agent states: single source of truth for validation and mapping\nVALID_AGENT_STATES = frozenset({\"idle\", \"writing\", \"researching\", \"executing\", \"syncing\", \"error\"})\nWORKING_STATES = frozenset({\"writing\", \"researching\", \"executing\"})  # subset used for auto-idle TTL\nSTATE_TO_AREA_MAP = {\n    \"idle\": \"breakroom\",\n    \"writing\": \"writing\",\n    \"researching\": \"writing\",\n    \"executing\": \"writing\",\n    \"syncing\": \"writing\",\n    \"error\": \"error\",\n}\n\n\napp = Flask(__name__, static_folder=FRONTEND_DIR, static_url_path=\"/static\")\napp.secret_key = os.getenv(\"FLASK_SECRET_KEY\") or os.getenv(\"STAR_OFFICE_SECRET\") or \"star-office-dev-secret-change-me\"\n\n# Session hardening\napp.config.update(\n    SESSION_COOKIE_HTTPONLY=True,\n    SESSION_COOKIE_SAMESITE=\"Lax\",\n    SESSION_COOKIE_SECURE=is_production_mode(),\n    PERMANENT_SESSION_LIFETIME=timedelta(hours=12),\n)\n\n# Guard join-agent critical section to enforce per-key concurrency under parallel requests\njoin_lock = threading.Lock()\n\n# Async background task registry for long-running operations (e.g. image generation)\n# Avoids Cloudflare 524 timeout (100s limit) by letting frontend poll for completion.\n_bg_tasks = {}  # task_id -> {\"status\": \"pending\"|\"done\"|\"error\", \"result\": ..., \"error\": ..., \"created_at\": ...}\n_bg_tasks_lock = threading.Lock()\n\n# Generate a version timestamp once at server startup for cache busting\nVERSION_TIMESTAMP = datetime.now().strftime(\"%Y%m%d_%H%M%S\")\nASSET_DRAWER_PASS_DEFAULT = os.getenv(\"ASSET_DRAWER_PASS\", \"1234\")\n\nif is_production_mode():\n    hardening_errors = []\n    if not is_strong_secret(str(app.secret_key)):\n        hardening_errors.append(\"FLASK_SECRET_KEY / STAR_OFFICE_SECRET is weak (need >=24 chars, non-default)\")\n    if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT):\n        hardening_errors.append(\"ASSET_DRAWER_PASS is weak (do not use default 1234; recommend >=8 chars)\")\n    if hardening_errors:\n        raise RuntimeError(\"Security hardening check failed in production mode: \" + \"; \".join(hardening_errors))\n\n\ndef _is_asset_editor_authed() -> bool:\n    return bool(session.get(\"asset_editor_authed\"))\n\n\ndef _require_asset_editor_auth():\n    if _is_asset_editor_authed():\n        return None\n    return jsonify({\"ok\": False, \"code\": \"UNAUTHORIZED\", \"msg\": \"Asset editor auth required\"}), 401\n\n\n@app.after_request\ndef add_no_cache_headers(response):\n    \"\"\"Apply cache policy by path:\n    - HTML/API/state: no-cache (always fresh)\n    - /static assets (2xx only): long cache (filenames are versioned with ?v=VERSION_TIMESTAMP)\n    - /static assets (non-2xx, e.g. 404): no-cache to prevent CDN from caching errors\n    \"\"\"\n    path = (request.path or \"\")\n    if path.startswith('/static/') and 200 <= response.status_code < 300:\n        response.headers[\"Cache-Control\"] = \"public, max-age=31536000, immutable\"\n        response.headers.pop(\"Pragma\", None)\n        response.headers.pop(\"Expires\", None)\n    else:\n        response.headers[\"Cache-Control\"] = \"no-cache, no-store, must-revalidate, max-age=0\"\n        response.headers[\"Pragma\"] = \"no-cache\"\n        response.headers[\"Expires\"] = \"0\"\n    return response\n\n# Default state\nDEFAULT_STATE = {\n    \"state\": \"idle\",\n    \"detail\": \"等待任务中...\",\n    \"progress\": 0,\n    \"updated_at\": datetime.now().isoformat()\n}\n\n\ndef load_state():\n    \"\"\"Load state from file.\n\n    Includes a simple auto-idle mechanism:\n    - If the last update is older than ttl_seconds (default 25s)\n      and the state is a \"working\" state, we fall back to idle.\n\n    This avoids the UI getting stuck at the desk when no new updates arrive.\n    \"\"\"\n    state = None\n    if os.path.exists(STATE_FILE):\n        try:\n            with open(STATE_FILE, \"r\", encoding=\"utf-8\") as f:\n                state = json.load(f)\n        except Exception:\n            state = None\n\n    if not isinstance(state, dict):\n        state = dict(DEFAULT_STATE)\n\n    # Auto-idle\n    try:\n        ttl = int(state.get(\"ttl_seconds\", 300))\n        updated_at = state.get(\"updated_at\")\n        s = state.get(\"state\", \"idle\")\n        if updated_at and s in WORKING_STATES:\n            # tolerate both with/without timezone\n            dt = datetime.fromisoformat(updated_at.replace(\"Z\", \"+00:00\"))\n            # Use UTC for aware datetimes; local time for naive.\n            if dt.tzinfo:\n                from datetime import timezone\n                age = (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds()\n            else:\n                age = (datetime.now() - dt).total_seconds()\n            if age > ttl:\n                state[\"state\"] = \"idle\"\n                state[\"detail\"] = \"待命中（自动回到休息区）\"\n                state[\"progress\"] = 0\n                state[\"updated_at\"] = datetime.now().isoformat()\n                # persist the auto-idle so every client sees it consistently\n                try:\n                    save_state(state)\n                except Exception:\n                    pass\n    except Exception:\n        pass\n\n    return state\n\n\ndef get_office_name_from_identity():\n    \"\"\"Read office display name from OpenClaw workspace IDENTITY.md (Name field) -> 'XXX的办公室'.\"\"\"\n    if not os.path.isfile(IDENTITY_FILE):\n        return None\n    try:\n        with open(IDENTITY_FILE, \"r\", encoding=\"utf-8\") as f:\n            content = f.read()\n        m = re.search(r\"-\\s*\\*\\*Name:\\*\\*\\s*(.+)\", content)\n        if m:\n            name = m.group(1).strip().replace(\"\\r\", \"\").split(\"\\n\")[0].strip()\n            return f\"{name}的办公室\" if name else None\n    except Exception:\n        pass\n    return None\n\n\ndef save_state(state: dict):\n    \"\"\"Save state to file\"\"\"\n    with open(STATE_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(state, f, ensure_ascii=False, indent=2)\n\n\ndef ensure_electron_standalone_snapshot():\n    \"\"\"Create Electron standalone frontend snapshot once if missing.\n\n    The snapshot is intentionally decoupled from the browser page:\n    - browser uses frontend/index.html\n    - Electron uses frontend/electron-standalone.html\n    \"\"\"\n    if os.path.exists(FRONTEND_ELECTRON_STANDALONE_FILE):\n        return\n    try:\n        shutil.copy2(FRONTEND_INDEX_FILE, FRONTEND_ELECTRON_STANDALONE_FILE)\n        print(f\"[standalone] created: {FRONTEND_ELECTRON_STANDALONE_FILE}\")\n    except Exception as e:\n        print(f\"[standalone] create failed: {e}\")\n\n\n# Initialize state\nif not os.path.exists(STATE_FILE):\n    save_state(DEFAULT_STATE)\nensure_electron_standalone_snapshot()\n\n\n_INDEX_HTML_CACHE = None\n\n\n@app.route(\"/\", methods=[\"GET\"])\ndef index():\n    \"\"\"Serve the pixel office UI with built-in version cache busting\"\"\"\n    # 默认禁用页面打开即换背景，避免首屏慢\n    # 如需启用，可配置 AUTO_ROTATE_HOME_ON_PAGE_OPEN=1\n    _maybe_apply_random_home_favorite()\n\n    global _INDEX_HTML_CACHE\n    if _INDEX_HTML_CACHE is None:\n        with open(FRONTEND_INDEX_FILE, \"r\", encoding=\"utf-8\") as f:\n            raw_html = f.read()\n        _INDEX_HTML_CACHE = raw_html.replace(\"{{VERSION_TIMESTAMP}}\", VERSION_TIMESTAMP)\n\n    resp = make_response(_INDEX_HTML_CACHE)\n    resp.headers[\"Content-Type\"] = \"text/html; charset=utf-8\"\n    return resp\n\n\n@app.route(\"/electron-standalone\", methods=[\"GET\"])\ndef electron_standalone_page():\n    \"\"\"Serve Electron-only standalone frontend page.\"\"\"\n    ensure_electron_standalone_snapshot()\n    target = FRONTEND_ELECTRON_STANDALONE_FILE\n    if not os.path.exists(target):\n        target = FRONTEND_INDEX_FILE\n    with open(target, \"r\", encoding=\"utf-8\") as f:\n        html = f.read()\n    html = html.replace(\"{{VERSION_TIMESTAMP}}\", VERSION_TIMESTAMP)\n    resp = make_response(html)\n    resp.headers[\"Content-Type\"] = \"text/html; charset=utf-8\"\n    return resp\n\n    resp.headers[\"Content-Type\"] = \"text/html; charset=utf-8\"\n    return resp\n\n\n@app.route(\"/join\", methods=[\"GET\"])\ndef join_page():\n    \"\"\"Serve the agent join page\"\"\"\n    with open(os.path.join(FRONTEND_DIR, \"join.html\"), \"r\", encoding=\"utf-8\") as f:\n        html = f.read()\n    resp = make_response(html)\n    resp.headers[\"Content-Type\"] = \"text/html; charset=utf-8\"\n    return resp\n\n\n@app.route(\"/invite\", methods=[\"GET\"])\ndef invite_page():\n    \"\"\"Serve human-facing invite instruction page\"\"\"\n    with open(os.path.join(FRONTEND_DIR, \"invite.html\"), \"r\", encoding=\"utf-8\") as f:\n        html = f.read()\n    resp = make_response(html)\n    resp.headers[\"Content-Type\"] = \"text/html; charset=utf-8\"\n    return resp\n\n\nDEFAULT_AGENTS = [\n    {\n        \"agentId\": \"star\",\n        \"name\": \"Star\",\n        \"isMain\": True,\n        \"state\": \"idle\",\n        \"detail\": \"待命中，随时准备为你服务\",\n        \"updated_at\": datetime.now().isoformat(),\n        \"area\": \"breakroom\",\n        \"source\": \"local\",\n        \"joinKey\": None,\n        \"authStatus\": \"approved\",\n        \"authExpiresAt\": None,\n        \"lastPushAt\": None\n    }\n]\n\n\ndef load_agents_state():\n    return _store_load_agents_state(AGENTS_STATE_FILE, DEFAULT_AGENTS)\n\n\ndef save_agents_state(agents):\n    _store_save_agents_state(AGENTS_STATE_FILE, agents)\n\n\ndef load_asset_positions():\n    return _store_load_asset_positions(ASSET_POSITIONS_FILE)\n\n\ndef save_asset_positions(data):\n    _store_save_asset_positions(ASSET_POSITIONS_FILE, data)\n\n\ndef load_asset_defaults():\n    return _store_load_asset_defaults(ASSET_DEFAULTS_FILE)\n\n\ndef save_asset_defaults(data):\n    _store_save_asset_defaults(ASSET_DEFAULTS_FILE, data)\n\n\ndef load_runtime_config():\n    return _store_load_runtime_config(RUNTIME_CONFIG_FILE)\n\n\ndef save_runtime_config(data):\n    _store_save_runtime_config(RUNTIME_CONFIG_FILE, data)\n\n\ndef _ensure_home_favorites_index():\n    os.makedirs(HOME_FAVORITES_DIR, exist_ok=True)\n    if not os.path.exists(HOME_FAVORITES_INDEX_FILE):\n        with open(HOME_FAVORITES_INDEX_FILE, \"w\", encoding=\"utf-8\") as f:\n            json.dump({\"items\": []}, f, ensure_ascii=False, indent=2)\n\n\ndef _load_home_favorites_index():\n    _ensure_home_favorites_index()\n    try:\n        with open(HOME_FAVORITES_INDEX_FILE, \"r\", encoding=\"utf-8\") as f:\n            data = json.load(f)\n            if isinstance(data, dict) and isinstance(data.get(\"items\"), list):\n                return data\n    except Exception:\n        pass\n    return {\"items\": []}\n\n\ndef _save_home_favorites_index(data):\n    _ensure_home_favorites_index()\n    with open(HOME_FAVORITES_INDEX_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(data, f, ensure_ascii=False, indent=2)\n\n\ndef _maybe_apply_random_home_favorite():\n    \"\"\"On page open, randomly apply one saved home favorite if available.\"\"\"\n    global _last_home_rotate_at\n\n    if not AUTO_ROTATE_HOME_ON_PAGE_OPEN:\n        return False, \"disabled\"\n\n    try:\n        now_ts = datetime.now().timestamp()\n        if _last_home_rotate_at and (now_ts - _last_home_rotate_at) < AUTO_ROTATE_MIN_INTERVAL_SECONDS:\n            return False, \"throttled\"\n\n        idx = _load_home_favorites_index()\n        items = idx.get(\"items\") or []\n        candidates = []\n        for it in items:\n            rel = (it.get(\"path\") or \"\").strip()\n            if not rel:\n                continue\n            abs_path = os.path.join(ROOT_DIR, rel)\n            if os.path.exists(abs_path):\n                candidates.append((rel, abs_path))\n\n        if not candidates:\n            return False, \"no-favorites\"\n\n        rel, src = random.choice(candidates)\n        target = FRONTEND_PATH / \"office_bg_small.webp\"\n        if not target.exists():\n            return False, \"missing-office-bg\"\n\n        shutil.copy2(src, str(target))\n        _last_home_rotate_at = now_ts\n        return True, rel\n    except Exception as e:\n        return False, str(e)\n\n\ndef load_join_keys():\n    return _store_load_join_keys(JOIN_KEYS_FILE)\n\n\ndef save_join_keys(data):\n    _store_save_join_keys(JOIN_KEYS_FILE, data)\n\n\ndef _ensure_magick_or_ffmpeg_available():\n    if shutil.which(\"magick\"):\n        return \"magick\"\n    if shutil.which(\"ffmpeg\"):\n        return \"ffmpeg\"\n    return None\n\n\ndef _probe_animated_frame_size(upload_path: str):\n    \"\"\"Return (w,h) from first frame if possible.\"\"\"\n    if Image is not None:\n        try:\n            with Image.open(upload_path) as im:\n                w, h = im.size\n                return int(w), int(h)\n        except Exception:\n            pass\n    # ffprobe fallback\n    if shutil.which(\"ffprobe\"):\n        try:\n            cmd = [\n                \"ffprobe\", \"-v\", \"error\",\n                \"-select_streams\", \"v:0\",\n                \"-show_entries\", \"stream=width,height\",\n                \"-of\", \"csv=p=0:s=x\",\n                upload_path,\n            ]\n            out = subprocess.check_output(cmd, stderr=subprocess.STDOUT, timeout=5).decode().strip()\n            if \"x\" in out:\n                w, h = out.split(\"x\", 1)\n                return int(w), int(h)\n        except Exception:\n            pass\n    return None, None\n\n\ndef _animated_to_spritesheet(\n    upload_path: str,\n    frame_w: int,\n    frame_h: int,\n    out_ext: str = \".webp\",\n    preserve_original: bool = True,\n    pixel_art: bool = True,\n    cols: int | None = None,\n    rows: int | None = None,\n):\n    \"\"\"Convert animated GIF/WEBP to spritesheet, return (out_path, columns, rows, frames, out_frame_w, out_frame_h).\"\"\"\n    backend = _ensure_magick_or_ffmpeg_available()\n    if not backend:\n        raise RuntimeError(\"未检测到 ImageMagick/ffmpeg，无法自动转换动图\")\n\n    ext = (out_ext or \".webp\").lower()\n    if ext not in {\".webp\", \".png\"}:\n        ext = \".webp\"\n\n    out_fd, out_path = tempfile.mkstemp(suffix=ext)\n    os.close(out_fd)\n\n    with tempfile.TemporaryDirectory() as td:\n        frames = 0\n        out_fw, out_fh = int(frame_w), int(frame_h)\n        if Image is not None:\n            try:\n                with Image.open(upload_path) as im:\n                    n = getattr(im, \"n_frames\", 1)\n                    # 默认保留用户原始帧尺寸（避免先压缩再放大导致像素糊）\n                    if preserve_original:\n                        out_fw, out_fh = im.size\n                    for i in range(n):\n                        im.seek(i)\n                        fr = im.convert(\"RGBA\")\n                        if not preserve_original and (fr.size != (out_fw, out_fh)):\n                            resample = Image.Resampling.NEAREST if pixel_art else Image.Resampling.LANCZOS\n                            fr = fr.resize((out_fw, out_fh), resample)\n                        fr.save(os.path.join(td, f\"f_{i:04d}.png\"), \"PNG\")\n                    frames = n\n            except Exception:\n                frames = 0\n\n        if frames <= 0:\n            cmd1 = f\"ffmpeg -y -i '{upload_path}' '{td}/f_%04d.png' >/dev/null 2>&1\"\n            if os.system(cmd1) != 0:\n                raise RuntimeError(\"动图抽帧失败（Pillow/ffmpeg 都失败）\")\n            files = sorted([x for x in os.listdir(td) if x.startswith(\"f_\") and x.endswith(\".png\")])\n            frames = len(files)\n            if frames <= 0:\n                raise RuntimeError(\"动图无有效帧\")\n\n        if backend == \"magick\":\n            # 像素风动图转精灵表默认无损，避免颜色/边缘被压缩糊掉\n            quality_flag = \"-define webp:lossless=true -define webp:method=6 -quality 100\" if ext == \".webp\" else \"\"\n            # 允许按 cols/rows 排布；默认单行\n            if cols is None or cols <= 0:\n                cols_eff = frames\n            else:\n                cols_eff = max(1, int(cols))\n            rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff))\n\n            # 先规范单帧尺寸\n            prep = \"\"\n            if not preserve_original:\n                magick_filter = \"-filter point\" if pixel_art else \"\"\n                prep = f\" {magick_filter} -resize {out_fw}x{out_fh}^ -gravity center -background none -extent {out_fw}x{out_fh}\"\n\n            cmd = (\n                f\"magick '{td}/f_*.png'{prep} \"\n                f\"-tile {cols_eff}x{rows_eff} -background none -geometry +0+0 {quality_flag} '{out_path}'\"\n            )\n            rc = os.system(cmd)\n            if rc != 0:\n                raise RuntimeError(\"ImageMagick 拼图失败\")\n            return out_path, cols_eff, rows_eff, frames, out_fw, out_fh\n\n        ffmpeg_quality = \"-lossless 1 -compression_level 6 -q:v 100\" if ext == \".webp\" else \"\"\n        cols_eff = max(1, int(cols)) if (cols is not None and cols > 0) else frames\n        rows_eff = max(1, int(rows)) if (rows is not None and rows > 0) else max(1, math.ceil(frames / cols_eff))\n        if preserve_original:\n            vf = f\"tile={cols_eff}x{rows_eff}\"\n        else:\n            scale_algo = \"neighbor\" if pixel_art else \"lanczos\"\n            vf = (\n                f\"scale={out_fw}:{out_fh}:force_original_aspect_ratio=decrease:flags={scale_algo},\"\n                f\"pad={out_fw}:{out_fh}:(ow-iw)/2:(oh-ih)/2:color=0x00000000,\"\n                f\"tile={cols_eff}x{rows_eff}\"\n            )\n        cmd2 = (\n            f\"ffmpeg -y -pattern_type glob -i '{td}/f_*.png' \"\n            f\"-vf '{vf}' \"\n            f\"{ffmpeg_quality} '{out_path}' >/dev/null 2>&1\"\n        )\n        if os.system(cmd2) != 0:\n            raise RuntimeError(\"ffmpeg 拼图失败\")\n        return out_path, frames, 1, frames, out_fw, out_fh\n\n\ndef normalize_agent_state(s):\n    \"\"\"Normalize agent state for compatibility.\n    Maps synonyms (e.g. working/busy -> writing, run/running -> executing) into VALID_AGENT_STATES.\n    Returns 'idle' for unknown values.\n    \"\"\"\n    if not s:\n        return 'idle'\n    s_lower = s.lower().strip()\n    if s_lower in {'working', 'busy', 'write'}:\n        return 'writing'\n    if s_lower in {'run', 'running', 'execute', 'exec'}:\n        return 'executing'\n    if s_lower in {'sync'}:\n        return 'syncing'\n    if s_lower in {'research', 'search'}:\n        return 'researching'\n    if s_lower in VALID_AGENT_STATES:\n        return s_lower\n    return 'idle'\n\n\n# User-facing model aliases -> provider model ids\nUSER_MODEL_TO_PROVIDER_MODELS = {\n    # 严格按用户要求：仅两种官方模型映射\n    \"nanobanana-pro\": [\n        \"nano-banana-pro-preview\",\n    ],\n    \"nanobanana-2\": [\n        \"gemini-2.5-flash-image\",\n    ],\n}\n\nPROVIDER_MODEL_TO_USER_MODEL = {\n    provider: user\n    for user, providers in USER_MODEL_TO_PROVIDER_MODELS.items()\n    for provider in providers\n}\n\n\ndef _normalize_user_model(model_name: str) -> str:\n    m = (model_name or \"\").strip()\n    if not m:\n        return \"nanobanana-pro\"\n    low = m.lower()\n    if low in USER_MODEL_TO_PROVIDER_MODELS:\n        return low\n    if low in PROVIDER_MODEL_TO_USER_MODEL:\n        return PROVIDER_MODEL_TO_USER_MODEL[low]\n    return \"nanobanana-pro\"\n\n\ndef _provider_model_candidates(user_model: str):\n    normalized = _normalize_user_model(user_model)\n    return list(USER_MODEL_TO_PROVIDER_MODELS.get(normalized, USER_MODEL_TO_PROVIDER_MODELS[\"nanobanana-pro\"]))\n\n\ndef _generate_rpg_background_to_webp(out_webp_path: str, width: int = 1280, height: int = 720, custom_prompt: str = \"\", speed_mode: str = \"fast\"):\n    \"\"\"Generate RPG-style room background and save as webp.\n\n    speed_mode:\n      - fast: use nanobanana-2 + 1024x576 intermediate + downscaled reference (faster)\n      - quality: use configured model (fallback nanobanana-pro) + full 1280x720 path\n    \"\"\"\n    runtime_cfg = load_runtime_config()\n    api_key = (runtime_cfg.get(\"gemini_api_key\") or \"\").strip()\n    if not api_key:\n        raise RuntimeError(\"MISSING_API_KEY\")\n    themes = [\n        \"8-bit dungeon guild room\",\n        \"8-bit stardew-valley inspired cozy farm tavern\",\n        \"8-bit nordic fantasy tavern\",\n        \"8-bit magitech workshop\",\n        \"8-bit elven forest inn\",\n        \"8-bit pixel cyber tavern\",\n        \"8-bit desert caravan inn\",\n        \"8-bit snow mountain lodge\",\n    ]\n    theme = random.choice(themes)\n\n    if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)):\n        raise RuntimeError(\"生图脚本环境缺失：gemini-image-generate 未安装\")\n\n    style_hint = (custom_prompt or \"\").strip()\n    if not style_hint:\n        style_hint = theme\n\n    # 默认使用更稳妥的 quality 档，避免 fast 模型在部分 API 通道不可用\n    mode = (speed_mode or \"quality\").strip().lower()\n    if mode not in {\"fast\", \"quality\"}:\n        mode = \"quality\"\n\n    configured_user_model = _normalize_user_model(runtime_cfg.get(\"gemini_model\") or \"nanobanana-pro\")\n    if mode == \"fast\":\n        preferred_user_model = \"nanobanana-2\"\n        # fast 也提高基础清晰度：从 1024x576 提升到 1152x648（牺牲少量速度）\n        gen_width, gen_height = 1152, 648\n        ref_width, ref_height = 1152, 648\n    else:\n        preferred_user_model = configured_user_model\n        gen_width, gen_height = width, height\n        ref_width, ref_height = width, height\n\n    # 同时规避可能触发 400 的特殊能力参数：\n    # 仅 nanobanana-2 走 aspect-ratio，nanobanana-pro 交给模型默认比例（后续再标准化到 1280x720）\n    allow_aspect_ratio = (preferred_user_model == \"nanobanana-2\")\n\n    prompt = (\n        \"Use a top-down pixel room composition compatible with an office game scene. \"\n        \"STRICTLY preserve the same room geometry, camera angle, wall/floor boundaries and major object placement as the provided reference image. \"\n        \"Keep region layout stable (left work area, center lounge, right error area). \"\n        \"Only change visual style/theme/material/lighting according to: \" + style_hint + \". \"\n        \"Do not add text or watermark. Retro 8-bit RPG style.\"\n    )\n\n    tmp_dir = tempfile.mkdtemp(prefix=\"rpg-bg-\")\n    cmd = [\n        GEMINI_PYTHON,\n        GEMINI_SCRIPT,\n        \"--prompt\", prompt,\n        \"--model\", configured_user_model,\n        \"--out-dir\", tmp_dir,\n        \"--cleanup\",\n    ]\n    if allow_aspect_ratio:\n        cmd.extend([\"--aspect-ratio\", \"16:9\"])\n\n    # 强约束：每次都带固定参考图，保持房间区域布局不漂移\n    ref_for_call = None\n    if os.path.exists(ROOM_REFERENCE_IMAGE):\n        ref_for_call = ROOM_REFERENCE_IMAGE\n        if mode == \"fast\" and Image is not None:\n            try:\n                ref_fast = os.path.join(tmp_dir, \"room-reference-fast.webp\")\n                with Image.open(ROOM_REFERENCE_IMAGE) as rim:\n                    rim = rim.convert(\"RGBA\").resize((ref_width, ref_height), Image.Resampling.LANCZOS)\n                    rim.save(ref_fast, \"WEBP\", quality=85, method=4)\n                ref_for_call = ref_fast\n            except Exception:\n                ref_for_call = ROOM_REFERENCE_IMAGE\n\n    if ref_for_call:\n        cmd.extend([\"--reference-image\", ref_for_call])\n\n    env = os.environ.copy()\n    # 运行时配置优先：只保留 GEMINI_API_KEY，避免脚本因双 key 报错\n    env.pop(\"GOOGLE_API_KEY\", None)\n    env[\"GEMINI_API_KEY\"] = api_key\n\n    def _run_cmd(cmd_args):\n        return subprocess.run(cmd_args, capture_output=True, text=True, env=env, timeout=240)\n\n    def _is_model_unavailable_error(text: str) -> bool:\n        low = (text or \"\").strip().lower()\n        return (\n            (\"not found\" in low and \"models/\" in low)\n            or (\"model_not_available\" in low)\n            or (\"model is not available\" in low)\n            or (\"configured model is not available\" in low)\n            or (\"this model is not available\" in low)\n            or (\"not supported for generatecontent\" in low)\n        )\n\n    def _with_model(cmd_args, model_name: str):\n        m = cmd_args[:]\n        if \"--model\" in m:\n            idx = m.index(\"--model\")\n            if idx + 1 < len(m):\n                m[idx + 1] = model_name\n        else:\n            m.extend([\"--model\", model_name])\n        return m\n\n    # 模型多级回退（仅允许两类用户模型：nanobanana-pro / nanobanana-2）\n    # 每个用户模型映射到若干 provider 真实模型。\n    user_model_order = [preferred_user_model, configured_user_model]\n    user_model_order = [m for i, m in enumerate(user_model_order) if m and m not in user_model_order[:i]]\n\n    model_candidates = []\n    for um in user_model_order:\n        model_candidates.extend(_provider_model_candidates(um))\n    # 去重并清理空项\n    model_candidates = [m for i, m in enumerate(model_candidates) if m and m not in model_candidates[:i]]\n\n    proc = None\n    last_err_text = \"\"\n    model_unavailable_count = 0\n\n    for mname in model_candidates:\n        env[\"GEMINI_MODEL\"] = mname\n        try_cmd = _with_model(cmd, mname)\n        proc = _run_cmd(try_cmd)\n        if proc.returncode == 0:\n            break\n\n        err_text = (proc.stderr or proc.stdout or \"\").strip()\n        last_err_text = err_text\n\n        # key 失效/泄漏：立即终止，不继续尝试\n        low = err_text.lower()\n        if \"your api key was reported as leaked\" in low or \"permission_denied\" in low:\n            raise RuntimeError(\"API_KEY_REVOKED_OR_LEAKED\")\n\n        if _is_model_unavailable_error(err_text):\n            model_unavailable_count += 1\n            continue\n\n        # 非模型不可用错误，直接返回真实错误\n        raise RuntimeError(f\"生图失败: {err_text}\")\n\n    if proc is None or proc.returncode != 0:\n        err_text = (last_err_text or \"\").strip()\n        if model_unavailable_count >= len(model_candidates) or _is_model_unavailable_error(err_text):\n            brief = (err_text or \"\").replace(\"\\n\", \" \")[:240]\n            raise RuntimeError(f\"MODEL_NOT_AVAILABLE::{brief}\")\n        raise RuntimeError(f\"生图失败: {err_text}\")\n\n    try:\n        result = json.loads(proc.stdout.strip().splitlines()[-1])\n    except Exception:\n        raise RuntimeError(\"生图结果解析失败\")\n\n    files = result.get(\"files\") or []\n    if not files:\n        raise RuntimeError(\"生图未返回文件\")\n\n    gen_path = files[0]\n    if not os.path.exists(gen_path):\n        raise RuntimeError(\"生图文件不存在\")\n\n    if Image is None:\n        raise RuntimeError(\"Pillow 不可用，无法做尺寸标准化\")\n\n    with Image.open(gen_path) as im:\n        im = im.convert(\"RGBA\")\n        # 质量模式优先保细节；快速模式优先速度\n        if mode == \"fast\":\n            im = im.resize((gen_width, gen_height), Image.Resampling.LANCZOS)\n            if (gen_width, gen_height) != (width, height):\n                # fast 的放大改为 LANCZOS，牺牲少量速度换更高细节\n                im = im.resize((width, height), Image.Resampling.LANCZOS)\n            im.save(out_webp_path, \"WEBP\", quality=96, method=6)\n        else:\n            # quality：确保输出标准尺寸，同时使用无损 webp，减少压缩损失\n            if im.size != (width, height):\n                im = im.resize((width, height), Image.Resampling.LANCZOS)\n            im.save(out_webp_path, \"WEBP\", lossless=True, quality=100, method=6)\n\n\ndef state_to_area(state):\n    \"\"\"Map agent state to office area (breakroom / writing / error).\"\"\"\n    return STATE_TO_AREA_MAP.get(state, \"breakroom\")\n\n\n# Ensure files exist\nif not os.path.exists(AGENTS_STATE_FILE):\n    save_agents_state(DEFAULT_AGENTS)\nif not os.path.exists(JOIN_KEYS_FILE):\n    if os.path.exists(os.path.join(ROOT_DIR, \"join-keys.sample.json\")):\n        try:\n            with open(os.path.join(ROOT_DIR, \"join-keys.sample.json\"), \"r\", encoding=\"utf-8\") as sf:\n                sample = json.load(sf)\n            save_join_keys(sample if isinstance(sample, dict) else {\"keys\": []})\n        except Exception:\n            save_join_keys({\"keys\": []})\n    else:\n        save_join_keys({\"keys\": []})\n\n# Tighten runtime-config file perms if exists\nif os.path.exists(RUNTIME_CONFIG_FILE):\n    try:\n        os.chmod(RUNTIME_CONFIG_FILE, 0o600)\n    except Exception:\n        pass\n\n\n@app.route(\"/agents\", methods=[\"GET\"])\ndef get_agents():\n    \"\"\"Get full agents list (for multi-agent UI), with auto-cleanup on access\"\"\"\n    agents = load_agents_state()\n    now = datetime.now()\n\n    cleaned_agents = []\n    keys_data = load_join_keys()\n\n    for a in agents:\n        if a.get(\"isMain\"):\n            cleaned_agents.append(a)\n            continue\n\n        auth_expires_at_str = a.get(\"authExpiresAt\")\n        auth_status = a.get(\"authStatus\", \"pending\")\n\n        # 1) 超时未批准自动 leave\n        if auth_status == \"pending\" and auth_expires_at_str:\n            try:\n                auth_expires_at = datetime.fromisoformat(auth_expires_at_str)\n                if now > auth_expires_at:\n                    key = a.get(\"joinKey\")\n                    if key:\n                        key_item = next((k for k in keys_data.get(\"keys\", []) if k.get(\"key\") == key), None)\n                        if key_item:\n                            key_item[\"used\"] = False\n                            key_item[\"usedBy\"] = None\n                            key_item[\"usedByAgentId\"] = None\n                            key_item[\"usedAt\"] = None\n                    continue\n            except Exception:\n                pass\n\n        # 2) 超时未推送自动离线（超过5分钟）\n        last_push_at_str = a.get(\"lastPushAt\")\n        if auth_status == \"approved\" and last_push_at_str:\n            try:\n                last_push_at = datetime.fromisoformat(last_push_at_str)\n                age = (now - last_push_at).total_seconds()\n                if age > 300:  # 5分钟无推送自动离线\n                    a[\"authStatus\"] = \"offline\"\n            except Exception:\n                pass\n\n        cleaned_agents.append(a)\n\n    save_agents_state(cleaned_agents)\n    save_join_keys(keys_data)\n\n    return jsonify(cleaned_agents)\n\n\n@app.route(\"/agent-approve\", methods=[\"POST\"])\ndef agent_approve():\n    \"\"\"Approve an agent (set authStatus to approved)\"\"\"\n    try:\n        data = request.get_json()\n        agent_id = (data.get(\"agentId\") or \"\").strip()\n        if not agent_id:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 agentId\"}), 400\n\n        agents = load_agents_state()\n        target = next((a for a in agents if a.get(\"agentId\") == agent_id and not a.get(\"isMain\")), None)\n        if not target:\n            return jsonify({\"ok\": False, \"msg\": \"未找到 agent\"}), 404\n\n        target[\"authStatus\"] = \"approved\"\n        target[\"authApprovedAt\"] = datetime.now().isoformat()\n        target[\"authExpiresAt\"] = (datetime.now() + timedelta(hours=24)).isoformat()  # 默认授权24h\n\n        save_agents_state(agents)\n        return jsonify({\"ok\": True, \"agentId\": agent_id, \"authStatus\": \"approved\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/agent-reject\", methods=[\"POST\"])\ndef agent_reject():\n    \"\"\"Reject an agent (set authStatus to rejected and optionally revoke key)\"\"\"\n    try:\n        data = request.get_json()\n        agent_id = (data.get(\"agentId\") or \"\").strip()\n        if not agent_id:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 agentId\"}), 400\n\n        agents = load_agents_state()\n        target = next((a for a in agents if a.get(\"agentId\") == agent_id and not a.get(\"isMain\")), None)\n        if not target:\n            return jsonify({\"ok\": False, \"msg\": \"未找到 agent\"}), 404\n\n        target[\"authStatus\"] = \"rejected\"\n        target[\"authRejectedAt\"] = datetime.now().isoformat()\n\n        # Optionally free join key back to unused\n        join_key = target.get(\"joinKey\")\n        keys_data = load_join_keys()\n        if join_key:\n            key_item = next((k for k in keys_data.get(\"keys\", []) if k.get(\"key\") == join_key), None)\n            if key_item:\n                key_item[\"used\"] = False\n                key_item[\"usedBy\"] = None\n                key_item[\"usedByAgentId\"] = None\n                key_item[\"usedAt\"] = None\n\n        # Remove from agents list\n        agents = [a for a in agents if a.get(\"agentId\") != agent_id or a.get(\"isMain\")]\n\n        save_agents_state(agents)\n        save_join_keys(keys_data)\n        return jsonify({\"ok\": True, \"agentId\": agent_id, \"authStatus\": \"rejected\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/join-agent\", methods=[\"POST\"])\ndef join_agent():\n    \"\"\"Add a new agent with one-time join key validation and pending auth\"\"\"\n    try:\n        data = request.get_json()\n        if not isinstance(data, dict) or not data.get(\"name\"):\n            return jsonify({\"ok\": False, \"msg\": \"请提供名字\"}), 400\n\n        name = data[\"name\"].strip()\n        state = data.get(\"state\", \"idle\")\n        detail = data.get(\"detail\", \"\")\n        join_key = data.get(\"joinKey\", \"\").strip()\n\n        # Normalize state early for compatibility\n        state = normalize_agent_state(state)\n\n        if not join_key:\n            return jsonify({\"ok\": False, \"msg\": \"请提供接入密钥\"}), 400\n\n        keys_data = load_join_keys()\n        key_item = next((k for k in keys_data.get(\"keys\", []) if k.get(\"key\") == join_key), None)\n        if not key_item:\n            return jsonify({\"ok\": False, \"msg\": \"接入密钥无效\"}), 403\n        # key 可复用：不再因为 used=true 拒绝\n\n        with join_lock:\n            # 在锁内重新读取，避免并发请求都基于同一旧快照通过校验\n            keys_data = load_join_keys()\n            key_item = next((k for k in keys_data.get(\"keys\", []) if k.get(\"key\") == join_key), None)\n            if not key_item:\n                return jsonify({\"ok\": False, \"msg\": \"接入密钥无效\"}), 403\n\n            # Key-level expiration check\n            key_expires_at_str = key_item.get(\"expiresAt\")\n            if key_expires_at_str:\n                try:\n                    key_expires_at = datetime.fromisoformat(key_expires_at_str)\n                    if datetime.now() > key_expires_at:\n                        return jsonify({\"ok\": False, \"msg\": \"该接入密钥已过期，活动已结束 🎉\"}), 403\n                except Exception:\n                    pass\n\n            agents = load_agents_state()\n\n            # 并发上限：同一个 key “同时在线”最多 3 个。\n            # 在线判定：lastPushAt/updated_at 在 5 分钟内；否则视为 offline，不计入并发。\n            now = datetime.now()\n            existing = next((a for a in agents if a.get(\"name\") == name and not a.get(\"isMain\")), None)\n            existing_id = existing.get(\"agentId\") if existing else None\n\n            def _age_seconds(dt_str):\n                if not dt_str:\n                    return None\n                try:\n                    dt = datetime.fromisoformat(dt_str)\n                    return (now - dt).total_seconds()\n                except Exception:\n                    return None\n\n            # opportunistic offline marking\n            for a in agents:\n                if a.get(\"isMain\"):\n                    continue\n                if a.get(\"authStatus\") != \"approved\":\n                    continue\n                age = _age_seconds(a.get(\"lastPushAt\"))\n                if age is None:\n                    age = _age_seconds(a.get(\"updated_at\"))\n                if age is not None and age > 300:\n                    a[\"authStatus\"] = \"offline\"\n\n            max_concurrent = int(key_item.get(\"maxConcurrent\", 3))\n            active_count = 0\n            for a in agents:\n                if a.get(\"isMain\"):\n                    continue\n                if a.get(\"agentId\") == existing_id:\n                    continue\n                if a.get(\"joinKey\") != join_key:\n                    continue\n                if a.get(\"authStatus\") != \"approved\":\n                    continue\n                age = _age_seconds(a.get(\"lastPushAt\"))\n                if age is None:\n                    age = _age_seconds(a.get(\"updated_at\"))\n                if age is None or age <= 300:\n                    active_count += 1\n\n            if active_count >= max_concurrent:\n                save_agents_state(agents)\n                return jsonify({\"ok\": False, \"msg\": f\"该接入密钥当前并发已达上限（{max_concurrent}），请稍后或换另一个 key\"}), 429\n\n            if existing:\n                existing[\"state\"] = state\n                existing[\"detail\"] = detail\n                existing[\"updated_at\"] = datetime.now().isoformat()\n                existing[\"area\"] = state_to_area(state)\n                existing[\"source\"] = \"remote-openclaw\"\n                existing[\"joinKey\"] = join_key\n                existing[\"authStatus\"] = \"approved\"\n                existing[\"authApprovedAt\"] = datetime.now().isoformat()\n                existing[\"authExpiresAt\"] = (datetime.now() + timedelta(hours=24)).isoformat()\n                existing[\"lastPushAt\"] = datetime.now().isoformat()  # join 视为上线，纳入并发/离线判定\n                if not existing.get(\"avatar\"):\n                    import random\n                    existing[\"avatar\"] = random.choice([\"guest_role_1\", \"guest_role_2\", \"guest_role_3\", \"guest_role_4\", \"guest_role_5\", \"guest_role_6\"])\n                agent_id = existing.get(\"agentId\")\n            else:\n                # Use ms + random suffix to avoid collisions under concurrent joins\n                import random\n                import string\n                agent_id = \"agent_\" + str(int(datetime.now().timestamp() * 1000)) + \"_\" + \"\".join(random.choices(string.ascii_lowercase + string.digits, k=4))\n                agents.append({\n                    \"agentId\": agent_id,\n                    \"name\": name,\n                    \"isMain\": False,\n                    \"state\": state,\n                    \"detail\": detail,\n                    \"updated_at\": datetime.now().isoformat(),\n                    \"area\": state_to_area(state),\n                    \"source\": \"remote-openclaw\",\n                    \"joinKey\": join_key,\n                    \"authStatus\": \"approved\",\n                    \"authApprovedAt\": datetime.now().isoformat(),\n                    \"authExpiresAt\": (datetime.now() + timedelta(hours=24)).isoformat(),\n                    \"lastPushAt\": datetime.now().isoformat(),\n                    \"avatar\": random.choice([\"guest_role_1\", \"guest_role_2\", \"guest_role_3\", \"guest_role_4\", \"guest_role_5\", \"guest_role_6\"])\n                })\n\n            key_item[\"used\"] = True\n            key_item[\"usedBy\"] = name\n            key_item[\"usedByAgentId\"] = agent_id\n            key_item[\"usedAt\"] = datetime.now().isoformat()\n            key_item[\"reusable\"] = True\n\n            # 拿到有效 key 直接批准，不再等待主人手动点击\n            # （状态已在上面 existing/new 分支写入）\n            save_agents_state(agents)\n            save_join_keys(keys_data)\n\n        return jsonify({\"ok\": True, \"agentId\": agent_id, \"authStatus\": \"approved\", \"nextStep\": \"已自动批准，立即开始推送状态\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/leave-agent\", methods=[\"POST\"])\ndef leave_agent():\n    \"\"\"Remove an agent and free its one-time join key for reuse (optional)\n\n    Prefer agentId (stable). Name is accepted for backward compatibility.\n    \"\"\"\n    try:\n        data = request.get_json()\n        if not isinstance(data, dict):\n            return jsonify({\"ok\": False, \"msg\": \"invalid json\"}), 400\n\n        agent_id = (data.get(\"agentId\") or \"\").strip()\n        name = (data.get(\"name\") or \"\").strip()\n        if not agent_id and not name:\n            return jsonify({\"ok\": False, \"msg\": \"请提供 agentId 或名字\"}), 400\n\n        agents = load_agents_state()\n\n        target = None\n        if agent_id:\n            target = next((a for a in agents if a.get(\"agentId\") == agent_id and not a.get(\"isMain\")), None)\n        if (not target) and name:\n            # fallback: remove by name only if agentId not provided\n            target = next((a for a in agents if a.get(\"name\") == name and not a.get(\"isMain\")), None)\n\n        if not target:\n            return jsonify({\"ok\": False, \"msg\": \"没有找到要离开的 agent\"}), 404\n\n        join_key = target.get(\"joinKey\")\n        new_agents = [a for a in agents if a.get(\"isMain\") or a.get(\"agentId\") != target.get(\"agentId\")]\n\n        # Optional: free key back to unused after leave\n        keys_data = load_join_keys()\n        if join_key:\n            key_item = next((k for k in keys_data.get(\"keys\", []) if k.get(\"key\") == join_key), None)\n            if key_item:\n                key_item[\"used\"] = False\n                key_item[\"usedBy\"] = None\n                key_item[\"usedByAgentId\"] = None\n                key_item[\"usedAt\"] = None\n\n        save_agents_state(new_agents)\n        save_join_keys(keys_data)\n        return jsonify({\"ok\": True})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/status\", methods=[\"GET\"])\ndef get_status():\n    \"\"\"Get current main state (backward compatibility). Optionally include officeName from IDENTITY.md.\"\"\"\n    state = load_state()\n    office_name = get_office_name_from_identity()\n    if office_name:\n        state[\"officeName\"] = office_name\n    return jsonify(state)\n\n\n@app.route(\"/agent-push\", methods=[\"POST\"])\ndef agent_push():\n    \"\"\"Remote openclaw actively pushes status to office.\n\n    Required fields:\n    - agentId\n    - joinKey\n    - state\n    Optional:\n    - detail\n    - name\n    \"\"\"\n    try:\n        data = request.get_json()\n        if not isinstance(data, dict):\n            return jsonify({\"ok\": False, \"msg\": \"invalid json\"}), 400\n\n        agent_id = (data.get(\"agentId\") or \"\").strip()\n        join_key = (data.get(\"joinKey\") or \"\").strip()\n        state = (data.get(\"state\") or \"\").strip()\n        detail = (data.get(\"detail\") or \"\").strip()\n        name = (data.get(\"name\") or \"\").strip()\n\n        if not agent_id or not join_key or not state:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 agentId/joinKey/state\"}), 400\n\n        state = normalize_agent_state(state)\n\n        keys_data = load_join_keys()\n        key_item = next((k for k in keys_data.get(\"keys\", []) if k.get(\"key\") == join_key), None)\n        if not key_item:\n            return jsonify({\"ok\": False, \"msg\": \"joinKey 无效\"}), 403\n\n        # Key-level expiration check\n        key_expires_at_str = key_item.get(\"expiresAt\")\n        if key_expires_at_str:\n            try:\n                key_expires_at = datetime.fromisoformat(key_expires_at_str)\n                if datetime.now() > key_expires_at:\n                    return jsonify({\"ok\": False, \"msg\": \"该接入密钥已过期，活动已结束 🎉\"}), 403\n            except Exception:\n                pass\n\n\n        agents = load_agents_state()\n        target = next((a for a in agents if a.get(\"agentId\") == agent_id and not a.get(\"isMain\")), None)\n        if not target:\n            return jsonify({\"ok\": False, \"msg\": \"agent 未注册，请先 join\"}), 404\n\n        # Auth check: only approved agents can push.\n        # Note: \"offline\" is a presence state (stale), not a revoked authorization.\n        # Allow offline agents to resume pushing and auto-promote them back to approved.\n        auth_status = target.get(\"authStatus\", \"pending\")\n        if auth_status not in {\"approved\", \"offline\"}:\n            return jsonify({\"ok\": False, \"msg\": \"agent 未获授权，请等待主人批准\"}), 403\n        if auth_status == \"offline\":\n            target[\"authStatus\"] = \"approved\"\n            target[\"authApprovedAt\"] = datetime.now().isoformat()\n            target[\"authExpiresAt\"] = (datetime.now() + timedelta(hours=24)).isoformat()\n\n        if target.get(\"joinKey\") != join_key:\n            return jsonify({\"ok\": False, \"msg\": \"joinKey 不匹配\"}), 403\n\n        target[\"state\"] = state\n        target[\"detail\"] = detail\n        if name:\n            target[\"name\"] = name\n        target[\"updated_at\"] = datetime.now().isoformat()\n        target[\"area\"] = state_to_area(state)\n        target[\"source\"] = \"remote-openclaw\"\n        target[\"lastPushAt\"] = datetime.now().isoformat()\n\n        save_agents_state(agents)\n        return jsonify({\"ok\": True, \"agentId\": agent_id, \"area\": target.get(\"area\")})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/health\", methods=[\"GET\"])\ndef health():\n    \"\"\"Health check\"\"\"\n    return jsonify({\n        \"status\": \"ok\",\n        \"service\": \"star-office-ui\",\n        \"timestamp\": datetime.now().isoformat(),\n    })\n\n\n@app.route(\"/yesterday-memo\", methods=[\"GET\"])\ndef get_yesterday_memo():\n    \"\"\"获取昨日小日记\"\"\"\n    try:\n        # 先尝试找昨天的文件\n        yesterday_str = get_yesterday_date_str()\n        yesterday_file = os.path.join(MEMORY_DIR, f\"{yesterday_str}.md\")\n        \n        target_file = None\n        target_date = yesterday_str\n        \n        if os.path.exists(yesterday_file):\n            target_file = yesterday_file\n        else:\n            # 如果昨天没有，找最近的一天\n            if os.path.exists(MEMORY_DIR):\n                files = [f for f in os.listdir(MEMORY_DIR) if f.endswith(\".md\") and re.match(r\"\\d{4}-\\d{2}-\\d{2}\\.md\", f)]\n                if files:\n                    files.sort(reverse=True)\n                    # 跳过今天的（如果存在）\n                    today_str = datetime.now().strftime(\"%Y-%m-%d\")\n                    for f in files:\n                        if f != f\"{today_str}.md\":\n                            target_file = os.path.join(MEMORY_DIR, f)\n                            target_date = f.replace(\".md\", \"\")\n                            break\n        \n        if target_file and os.path.exists(target_file):\n            memo_content = extract_memo_from_file(target_file)\n            return jsonify({\n                \"success\": True,\n                \"date\": target_date,\n                \"memo\": memo_content\n            })\n        else:\n            return jsonify({\n                \"success\": False,\n                \"msg\": \"没有找到昨日日记\"\n            })\n    except Exception as e:\n        return jsonify({\n            \"success\": False,\n            \"msg\": str(e)\n        }), 500\n\n\n@app.route(\"/set_state\", methods=[\"POST\"])\ndef set_state_endpoint():\n    \"\"\"Set state via POST (for UI control panel)\"\"\"\n    try:\n        data = request.get_json()\n        if not isinstance(data, dict):\n            return jsonify({\"status\": \"error\", \"msg\": \"invalid json\"}), 400\n        state = load_state()\n        if \"state\" in data:\n            s = data[\"state\"]\n            if s in VALID_AGENT_STATES:\n                state[\"state\"] = s\n        if \"detail\" in data:\n            state[\"detail\"] = data[\"detail\"]\n        state[\"updated_at\"] = datetime.now().isoformat()\n        save_state(state)\n        return jsonify({\"status\": \"ok\"})\n    except Exception as e:\n        return jsonify({\"status\": \"error\", \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/template.zip\", methods=[\"GET\"])\ndef assets_template_download():\n    if not os.path.exists(ASSET_TEMPLATE_ZIP):\n        return jsonify({\"ok\": False, \"msg\": \"模板包不存在，请先生成\"}), 404\n    return send_from_directory(ROOT_DIR, \"assets-replace-template.zip\", as_attachment=True)\n\n\n@app.route(\"/assets/list\", methods=[\"GET\"])\ndef assets_list():\n    items = []\n    for p in FRONTEND_PATH.rglob(\"*\"):\n        if not p.is_file():\n            continue\n        rel = p.relative_to(FRONTEND_PATH).as_posix()\n        if rel.startswith(\"fonts/\"):\n            continue\n        if p.suffix.lower() not in ASSET_ALLOWED_EXTS:\n            continue\n        st = p.stat()\n        width = None\n        height = None\n        if Image is not None:\n            try:\n                with Image.open(p) as im:\n                    width, height = im.size\n            except Exception:\n                pass\n        items.append({\n            \"path\": rel,\n            \"size\": st.st_size,\n            \"ext\": p.suffix.lower(),\n            \"width\": width,\n            \"height\": height,\n            \"mtime\": datetime.fromtimestamp(st.st_mtime).isoformat(),\n        })\n    items.sort(key=lambda x: x[\"path\"])\n    return jsonify({\"ok\": True, \"count\": len(items), \"items\": items})\n\n\ndef _bg_generate_worker(task_id: str, custom_prompt: str, speed_mode: str):\n    \"\"\"Background worker for RPG background generation.\"\"\"\n    try:\n        target = FRONTEND_PATH / \"office_bg_small.webp\"\n\n        # 覆盖前保留最近一次备份\n        bak = target.with_suffix(target.suffix + \".bak\")\n        shutil.copy2(target, bak)\n\n        _generate_rpg_background_to_webp(\n            str(target),\n            width=1280,\n            height=720,\n            custom_prompt=custom_prompt,\n            speed_mode=speed_mode,\n        )\n\n        # 每次生成都归档一份历史底图（可回溯风格演化）\n        os.makedirs(BG_HISTORY_DIR, exist_ok=True)\n        ts = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n        hist_file = os.path.join(BG_HISTORY_DIR, f\"office_bg_small-{ts}.webp\")\n        shutil.copy2(target, hist_file)\n\n        st = target.stat()\n        with _bg_tasks_lock:\n            _bg_tasks[task_id] = {\n                \"status\": \"done\",\n                \"result\": {\n                    \"ok\": True,\n                    \"path\": \"office_bg_small.webp\",\n                    \"size\": st.st_size,\n                    \"history\": os.path.relpath(hist_file, ROOT_DIR),\n                    \"speed_mode\": speed_mode,\n                    \"msg\": \"已生成并替换 RPG 房间底图（已自动归档）\",\n                },\n            }\n    except Exception as e:\n        msg = str(e)\n        error_result = {\"ok\": False, \"msg\": msg}\n        if msg == \"MISSING_API_KEY\":\n            error_result[\"code\"] = \"MISSING_API_KEY\"\n            error_result[\"msg\"] = \"Missing GEMINI_API_KEY or GOOGLE_API_KEY\"\n        elif msg == \"API_KEY_REVOKED_OR_LEAKED\":\n            error_result[\"code\"] = \"API_KEY_REVOKED_OR_LEAKED\"\n            error_result[\"msg\"] = \"API key is revoked or flagged as leaked. Please rotate to a new key.\"\n        elif msg.startswith(\"MODEL_NOT_AVAILABLE\"):\n            error_result[\"code\"] = \"MODEL_NOT_AVAILABLE\"\n            error_result[\"msg\"] = \"Configured model is not available for this API key/channel.\"\n            if \"::\" in msg:\n                error_result[\"detail\"] = msg.split(\"::\", 1)[1]\n        with _bg_tasks_lock:\n            _bg_tasks[task_id] = {\"status\": \"error\", \"result\": error_result}\n\n\n@app.route(\"/assets/generate-rpg-background\", methods=[\"POST\"])\ndef assets_generate_rpg_background():\n    \"\"\"Start async RPG background generation. Returns a task_id for polling.\"\"\"\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        req = request.get_json(silent=True) or {}\n        custom_prompt = (req.get(\"prompt\") or \"\").strip() if isinstance(req, dict) else \"\"\n        speed_mode = (req.get(\"speed_mode\") or \"quality\").strip().lower() if isinstance(req, dict) else \"quality\"\n        if speed_mode not in {\"fast\", \"quality\"}:\n            speed_mode = \"fast\"\n\n        target = FRONTEND_PATH / \"office_bg_small.webp\"\n        if not target.exists():\n            return jsonify({\"ok\": False, \"msg\": \"office_bg_small.webp 不存在\"}), 404\n\n        # Pre-flight checks that can fail fast (before spawning thread)\n        runtime_cfg = load_runtime_config()\n        api_key = (runtime_cfg.get(\"gemini_api_key\") or \"\").strip()\n        if not api_key:\n            return jsonify({\"ok\": False, \"code\": \"MISSING_API_KEY\", \"msg\": \"Missing GEMINI_API_KEY or GOOGLE_API_KEY\"}), 400\n        if not (os.path.exists(GEMINI_PYTHON) and os.path.exists(GEMINI_SCRIPT)):\n            return jsonify({\"ok\": False, \"msg\": \"生图脚本环境缺失：gemini-image-generate 未安装\"}), 500\n\n        # Check if another generation is already running\n        with _bg_tasks_lock:\n            for tid, task in _bg_tasks.items():\n                if task.get(\"status\") == \"pending\":\n                    return jsonify({\"ok\": True, \"async\": True, \"task_id\": tid, \"msg\": \"已有生图任务进行中，请等待完成\"}), 200\n\n        # Create async task\n        import string as _string\n        task_id = \"gen_\" + str(int(datetime.now().timestamp() * 1000)) + \"_\" + \"\".join(random.choices(_string.ascii_lowercase + _string.digits, k=4))\n        with _bg_tasks_lock:\n            _bg_tasks[task_id] = {\"status\": \"pending\", \"created_at\": datetime.now().isoformat()}\n\n        t = threading.Thread(target=_bg_generate_worker, args=(task_id, custom_prompt, speed_mode), daemon=True)\n        t.start()\n\n        return jsonify({\"ok\": True, \"async\": True, \"task_id\": task_id, \"msg\": \"生图任务已启动，请通过 task_id 轮询结果\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/generate-rpg-background/poll\", methods=[\"GET\"])\ndef assets_generate_rpg_background_poll():\n    \"\"\"Poll async generation task status.\"\"\"\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    task_id = (request.args.get(\"task_id\") or \"\").strip()\n    if not task_id:\n        return jsonify({\"ok\": False, \"msg\": \"缺少 task_id\"}), 400\n    with _bg_tasks_lock:\n        task = _bg_tasks.get(task_id)\n    if not task:\n        return jsonify({\"ok\": False, \"msg\": \"任务不存在\"}), 404\n    status = task.get(\"status\", \"pending\")\n    if status == \"pending\":\n        return jsonify({\"ok\": True, \"status\": \"pending\", \"msg\": \"生图进行中...\"})\n    elif status == \"done\":\n        # Clean up task after delivering result\n        with _bg_tasks_lock:\n            _bg_tasks.pop(task_id, None)\n        return jsonify({\"ok\": True, \"status\": \"done\", **task.get(\"result\", {})})\n    else:\n        with _bg_tasks_lock:\n            _bg_tasks.pop(task_id, None)\n        result = task.get(\"result\", {})\n        code = 400 if result.get(\"code\") else 500\n        return jsonify({\"ok\": False, \"status\": \"error\", **result}), code\n\n\n@app.route(\"/assets/restore-reference-background\", methods=[\"POST\"])\ndef assets_restore_reference_background():\n    \"\"\"Restore office_bg_small.webp from fixed reference image.\"\"\"\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        target = FRONTEND_PATH / \"office_bg_small.webp\"\n        if not target.exists():\n            return jsonify({\"ok\": False, \"msg\": \"office_bg_small.webp 不存在\"}), 404\n        if not os.path.exists(ROOM_REFERENCE_IMAGE):\n            return jsonify({\"ok\": False, \"msg\": \"参考图不存在\"}), 404\n\n        # 备份当前底图\n        bak = target.with_suffix(target.suffix + \".bak\")\n        shutil.copy2(target, bak)\n\n        # 快速路径：若参考图已是 1280x720 的 webp，直接拷贝（秒级）\n        ref_ext = os.path.splitext(ROOM_REFERENCE_IMAGE)[1].lower()\n        fast_copied = False\n        if ref_ext == '.webp':\n            try:\n                with Image.open(ROOM_REFERENCE_IMAGE) as rim:\n                    if rim.size == (1280, 720):\n                        shutil.copy2(ROOM_REFERENCE_IMAGE, target)\n                        fast_copied = True\n            except Exception:\n                fast_copied = False\n\n        # 慢路径：仅在必要时重编码\n        if not fast_copied:\n            if Image is None:\n                return jsonify({\"ok\": False, \"msg\": \"Pillow 不可用\"}), 500\n            with Image.open(ROOM_REFERENCE_IMAGE) as im:\n                im = im.convert(\"RGBA\").resize((1280, 720), Image.Resampling.LANCZOS)\n                im.save(target, \"WEBP\", quality=92, method=6)\n\n        st = target.stat()\n        return jsonify({\n            \"ok\": True,\n            \"path\": \"office_bg_small.webp\",\n            \"size\": st.st_size,\n            \"msg\": \"已恢复初始底图\",\n        })\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/restore-last-generated-background\", methods=[\"POST\"])\ndef assets_restore_last_generated_background():\n    \"\"\"Restore office_bg_small.webp from latest bg-history snapshot.\"\"\"\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        target = FRONTEND_PATH / \"office_bg_small.webp\"\n        if not target.exists():\n            return jsonify({\"ok\": False, \"msg\": \"office_bg_small.webp 不存在\"}), 404\n\n        if not os.path.isdir(BG_HISTORY_DIR):\n            return jsonify({\"ok\": False, \"msg\": \"暂无历史底图\"}), 404\n\n        files = [\n            os.path.join(BG_HISTORY_DIR, x)\n            for x in os.listdir(BG_HISTORY_DIR)\n            if x.startswith(\"office_bg_small-\") and x.endswith(\".webp\")\n        ]\n        if not files:\n            return jsonify({\"ok\": False, \"msg\": \"暂无历史底图\"}), 404\n\n        latest = max(files, key=lambda p: os.path.getmtime(p))\n\n        bak = target.with_suffix(target.suffix + \".bak\")\n        shutil.copy2(target, bak)\n        shutil.copy2(latest, target)\n\n        st = target.stat()\n        return jsonify({\n            \"ok\": True,\n            \"path\": \"office_bg_small.webp\",\n            \"size\": st.st_size,\n            \"from\": os.path.relpath(latest, ROOT_DIR),\n            \"msg\": \"已回退到最近一次生成底图\",\n        })\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/home-favorites/list\", methods=[\"GET\"])\ndef assets_home_favorites_list():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = _load_home_favorites_index()\n        items = data.get(\"items\") or []\n        out = []\n        for it in items:\n            rel = (it.get(\"path\") or \"\").strip()\n            if not rel:\n                continue\n            abs_path = os.path.join(ROOT_DIR, rel)\n            if not os.path.exists(abs_path):\n                continue\n            fn = os.path.basename(rel)\n            out.append({\n                \"id\": it.get(\"id\"),\n                \"path\": rel,\n                \"url\": f\"/assets/home-favorites/file/{fn}\",\n                \"thumb_url\": f\"/assets/home-favorites/file/{fn}\",\n                \"created_at\": it.get(\"created_at\") or \"\",\n            })\n        out.sort(key=lambda x: x.get(\"created_at\") or \"\", reverse=True)\n        return jsonify({\"ok\": True, \"items\": out})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/home-favorites/file/<path:filename>\", methods=[\"GET\"])\ndef assets_home_favorites_file(filename):\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    return send_from_directory(HOME_FAVORITES_DIR, filename)\n\n\n@app.route(\"/assets/home-favorites/save-current\", methods=[\"POST\"])\ndef assets_home_favorites_save_current():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        src = FRONTEND_PATH / \"office_bg_small.webp\"\n        if not src.exists():\n            return jsonify({\"ok\": False, \"msg\": \"office_bg_small.webp 不存在\"}), 404\n\n        _ensure_home_favorites_index()\n        ts = datetime.now().strftime(\"%Y%m%d-%H%M%S\")\n        item_id = f\"home-{ts}\"\n        fn = f\"{item_id}.webp\"\n        dst = os.path.join(HOME_FAVORITES_DIR, fn)\n        shutil.copy2(str(src), dst)\n\n        idx = _load_home_favorites_index()\n        items = idx.get(\"items\") or []\n        items.insert(0, {\n            \"id\": item_id,\n            \"path\": os.path.relpath(dst, ROOT_DIR),\n            \"created_at\": datetime.now().isoformat(timespec=\"seconds\"),\n        })\n\n        # 控制收藏数量上限，清理最旧项\n        if len(items) > HOME_FAVORITES_MAX:\n            extra = items[HOME_FAVORITES_MAX:]\n            items = items[:HOME_FAVORITES_MAX]\n            for it in extra:\n                try:\n                    p = os.path.join(ROOT_DIR, it.get(\"path\") or \"\")\n                    if os.path.exists(p):\n                        os.remove(p)\n                except Exception:\n                    pass\n\n        idx[\"items\"] = items\n        _save_home_favorites_index(idx)\n        return jsonify({\"ok\": True, \"id\": item_id, \"path\": os.path.relpath(dst, ROOT_DIR), \"msg\": \"已收藏当前地图\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/home-favorites/delete\", methods=[\"POST\"])\ndef assets_home_favorites_delete():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = request.get_json(silent=True) or {}\n        item_id = (data.get(\"id\") or \"\").strip()\n        if not item_id:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 id\"}), 400\n\n        idx = _load_home_favorites_index()\n        items = idx.get(\"items\") or []\n        hit = next((x for x in items if (x.get(\"id\") or \"\") == item_id), None)\n        if not hit:\n            return jsonify({\"ok\": False, \"msg\": \"收藏项不存在\"}), 404\n\n        rel = hit.get(\"path\") or \"\"\n        abs_path = os.path.join(ROOT_DIR, rel)\n        if os.path.exists(abs_path):\n            try:\n                os.remove(abs_path)\n            except Exception:\n                pass\n\n        idx[\"items\"] = [x for x in items if (x.get(\"id\") or \"\") != item_id]\n        _save_home_favorites_index(idx)\n        return jsonify({\"ok\": True, \"id\": item_id, \"msg\": \"已删除收藏\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/home-favorites/apply\", methods=[\"POST\"])\ndef assets_home_favorites_apply():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = request.get_json(silent=True) or {}\n        item_id = (data.get(\"id\") or \"\").strip()\n        if not item_id:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 id\"}), 400\n\n        idx = _load_home_favorites_index()\n        items = idx.get(\"items\") or []\n        hit = next((x for x in items if (x.get(\"id\") or \"\") == item_id), None)\n        if not hit:\n            return jsonify({\"ok\": False, \"msg\": \"收藏项不存在\"}), 404\n\n        src = os.path.join(ROOT_DIR, hit.get(\"path\") or \"\")\n        if not os.path.exists(src):\n            return jsonify({\"ok\": False, \"msg\": \"收藏文件不存在\"}), 404\n\n        target = FRONTEND_PATH / \"office_bg_small.webp\"\n        if not target.exists():\n            return jsonify({\"ok\": False, \"msg\": \"office_bg_small.webp 不存在\"}), 404\n\n        bak = target.with_suffix(target.suffix + \".bak\")\n        shutil.copy2(str(target), str(bak))\n        shutil.copy2(src, str(target))\n\n        st = target.stat()\n        return jsonify({\"ok\": True, \"path\": \"office_bg_small.webp\", \"size\": st.st_size, \"from\": hit.get(\"path\"), \"msg\": \"已应用收藏地图\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/auth\", methods=[\"POST\"])\ndef assets_auth():\n    try:\n        data = request.get_json(silent=True) or {}\n        pwd = (data.get(\"password\") or \"\").strip()\n        if pwd and pwd == ASSET_DRAWER_PASS_DEFAULT:\n            session[\"asset_editor_authed\"] = True\n            return jsonify({\"ok\": True, \"msg\": \"认证成功\"})\n        return jsonify({\"ok\": False, \"msg\": \"验证码错误\"}), 401\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/auth/status\", methods=[\"GET\"])\ndef assets_auth_status():\n    return jsonify({\n        \"ok\": True,\n        \"authed\": _is_asset_editor_authed(),\n        \"drawer_default_pass\": ASSET_DRAWER_PASS_DEFAULT == \"1234\",\n    })\n\n\n@app.route(\"/assets/positions\", methods=[\"GET\"])\ndef assets_positions_get():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        return jsonify({\"ok\": True, \"items\": load_asset_positions()})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/positions\", methods=[\"POST\"])\ndef assets_positions_set():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = request.get_json(silent=True) or {}\n        key = (data.get(\"key\") or \"\").strip()\n        x = data.get(\"x\")\n        y = data.get(\"y\")\n        scale = data.get(\"scale\")\n        if not key:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 key\"}), 400\n        if x is None or y is None:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 x/y\"}), 400\n        x = float(x)\n        y = float(y)\n        if scale is None:\n            scale = 1.0\n        scale = float(scale)\n\n        all_pos = load_asset_positions()\n        all_pos[key] = {\"x\": x, \"y\": y, \"scale\": scale, \"updated_at\": datetime.now().isoformat()}\n        save_asset_positions(all_pos)\n        return jsonify({\"ok\": True, \"key\": key, \"x\": x, \"y\": y, \"scale\": scale})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/defaults\", methods=[\"GET\"])\ndef assets_defaults_get():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        return jsonify({\"ok\": True, \"items\": load_asset_defaults()})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/defaults\", methods=[\"POST\"])\ndef assets_defaults_set():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = request.get_json(silent=True) or {}\n        key = (data.get(\"key\") or \"\").strip()\n        x = data.get(\"x\")\n        y = data.get(\"y\")\n        scale = data.get(\"scale\")\n        if not key:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 key\"}), 400\n        if x is None or y is None:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 x/y\"}), 400\n        x = float(x)\n        y = float(y)\n        if scale is None:\n            scale = 1.0\n        scale = float(scale)\n\n        all_defaults = load_asset_defaults()\n        all_defaults[key] = {\"x\": x, \"y\": y, \"scale\": scale, \"updated_at\": datetime.now().isoformat()}\n        save_asset_defaults(all_defaults)\n        return jsonify({\"ok\": True, \"key\": key, \"x\": x, \"y\": y, \"scale\": scale})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/config/gemini\", methods=[\"GET\"])\ndef gemini_config_get():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        cfg = load_runtime_config()\n        key = (cfg.get(\"gemini_api_key\") or \"\").strip()\n        masked = (\"*\" * max(0, len(key) - 4)) + key[-4:] if key else \"\"\n        return jsonify({\n            \"ok\": True,\n            \"has_api_key\": bool(key),\n            \"api_key_masked\": masked,\n            \"gemini_model\": _normalize_user_model(cfg.get(\"gemini_model\") or \"nanobanana-pro\"),\n        })\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/config/gemini\", methods=[\"POST\"])\ndef gemini_config_set():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = request.get_json(silent=True) or {}\n        api_key = (data.get(\"api_key\") or \"\").strip()\n        model = _normalize_user_model((data.get(\"model\") or \"\").strip() or \"nanobanana-pro\")\n        payload = {\"gemini_model\": model}\n        if api_key:\n            payload[\"gemini_api_key\"] = api_key\n        save_runtime_config(payload)\n        return jsonify({\"ok\": True, \"msg\": \"Gemini 配置已保存\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/restore-default\", methods=[\"POST\"])\ndef assets_restore_default():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = request.get_json(silent=True) or {}\n        rel_path = (data.get(\"path\") or \"\").strip().lstrip(\"/\")\n        if not rel_path:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 path\"}), 400\n\n        target = (FRONTEND_PATH / rel_path).resolve()\n        try:\n            target.relative_to(FRONTEND_PATH.resolve())\n        except Exception:\n            return jsonify({\"ok\": False, \"msg\": \"非法 path\"}), 400\n\n        if not target.exists():\n            return jsonify({\"ok\": False, \"msg\": \"目标文件不存在\"}), 404\n\n        root, ext = os.path.splitext(str(target))\n        default_path = root + ext + \".default\"\n        if not os.path.exists(default_path):\n            return jsonify({\"ok\": False, \"msg\": \"未找到默认资产快照\"}), 404\n\n        # 回滚前保留上一版\n        bak = str(target) + \".bak\"\n        if os.path.exists(str(target)):\n            shutil.copy2(str(target), bak)\n\n        shutil.copy2(default_path, str(target))\n        st = os.stat(str(target))\n        return jsonify({\"ok\": True, \"path\": rel_path, \"size\": st.st_size, \"msg\": \"已重置为默认资产\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/restore-prev\", methods=[\"POST\"])\ndef assets_restore_prev():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        data = request.get_json(silent=True) or {}\n        rel_path = (data.get(\"path\") or \"\").strip().lstrip(\"/\")\n        if not rel_path:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 path\"}), 400\n\n        target = (FRONTEND_PATH / rel_path).resolve()\n        try:\n            target.relative_to(FRONTEND_PATH.resolve())\n        except Exception:\n            return jsonify({\"ok\": False, \"msg\": \"非法 path\"}), 400\n\n        bak = str(target) + \".bak\"\n        if not os.path.exists(bak):\n            return jsonify({\"ok\": False, \"msg\": \"未找到上一版备份\"}), 404\n\n        shutil.copy2(str(target), bak + \".tmp\") if os.path.exists(str(target)) else None\n        shutil.copy2(bak, str(target))\n        st = os.stat(str(target))\n        return jsonify({\"ok\": True, \"path\": rel_path, \"size\": st.st_size, \"msg\": \"已回退到上一版\"})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\n@app.route(\"/assets/upload\", methods=[\"POST\"])\ndef assets_upload():\n    guard = _require_asset_editor_auth()\n    if guard:\n        return guard\n    try:\n        rel_path = (request.form.get(\"path\") or \"\").strip().lstrip(\"/\")\n        backup = (request.form.get(\"backup\") or \"1\").strip() != \"0\"\n        f = request.files.get(\"file\")\n\n        if not rel_path or f is None:\n            return jsonify({\"ok\": False, \"msg\": \"缺少 path 或 file\"}), 400\n\n        target = (FRONTEND_PATH / rel_path).resolve()\n        try:\n            target.relative_to(FRONTEND_PATH.resolve())\n        except Exception:\n            return jsonify({\"ok\": False, \"msg\": \"非法 path\"}), 400\n\n        if target.suffix.lower() not in ASSET_ALLOWED_EXTS:\n            return jsonify({\"ok\": False, \"msg\": \"仅允许上传图片/美术资源类型\"}), 400\n\n        if not target.exists():\n            return jsonify({\"ok\": False, \"msg\": \"目标文件不存在，请先从 /assets/list 选择 path\"}), 404\n\n        target.parent.mkdir(parents=True, exist_ok=True)\n\n        # 首次上传前固化默认资产快照，供“重置为默认资产”使用\n        default_snap = Path(str(target) + \".default\")\n        if not default_snap.exists():\n            try:\n                shutil.copy2(target, default_snap)\n            except Exception:\n                pass\n\n        if backup:\n            bak = target.with_suffix(target.suffix + \".bak\")\n            shutil.copy2(target, bak)\n\n        auto_sheet = (request.form.get(\"auto_spritesheet\") or \"0\").strip() == \"1\"\n        ext_name = (f.filename or \"\").lower()\n\n        if auto_sheet and target.suffix.lower() in {\".webp\", \".png\"}:\n            with tempfile.NamedTemporaryFile(suffix=os.path.splitext(ext_name)[1] or \".gif\", delete=False) as tf:\n                src_path = tf.name\n                f.save(src_path)\n            try:\n                in_w, in_h = _probe_animated_frame_size(src_path)\n                frame_w = int(request.form.get(\"frame_w\") or (in_w or 64))\n                frame_h = int(request.form.get(\"frame_h\") or (in_h or 64))\n\n                # 如果是静态图上传到精灵表目标，按网格切片而不是整图覆盖\n                if not (ext_name.endswith(\".gif\") or ext_name.endswith(\".webp\")) and Image is not None:\n                    try:\n                        with Image.open(src_path) as sim:\n                            sim = sim.convert(\"RGBA\")\n                            sw, sh = sim.size\n                            if frame_w <= 0 or frame_h <= 0:\n                                frame_w, frame_h = sw, sh\n                            cols = max(1, sw // frame_w)\n                            rows = max(1, sh // frame_h)\n                            sheet_w = cols * frame_w\n                            sheet_h = rows * frame_h\n                            if sheet_w <= 0 or sheet_h <= 0:\n                                raise RuntimeError(\"静态图尺寸与帧规格不匹配\")\n\n                            cropped = sim.crop((0, 0, sheet_w, sheet_h))\n                            # 目标是 webp 仍按无损保存，避免像素损失\n                            if target.suffix.lower() == \".webp\":\n                                cropped.save(str(target), \"WEBP\", lossless=True, quality=100, method=6)\n                            else:\n                                cropped.save(str(target), \"PNG\")\n\n                            st = target.stat()\n                            return jsonify({\n                                \"ok\": True,\n                                \"path\": rel_path,\n                                \"size\": st.st_size,\n                                \"backup\": backup,\n                                \"converted\": {\n                                    \"from\": ext_name.split(\".\")[-1] if \".\" in ext_name else \"image\",\n                                    \"to\": \"webp_spritesheet\" if target.suffix.lower() == \".webp\" else \"png_spritesheet\",\n                                    \"frame_w\": frame_w,\n                                    \"frame_h\": frame_h,\n                                    \"columns\": cols,\n                                    \"rows\": rows,\n                                    \"frames\": cols * rows,\n                                    \"preserve_original\": False,\n                                    \"pixel_art\": True,\n                                }\n                            })\n                    finally:\n                        pass\n\n                # 默认：优先保留输入帧尺寸；若前端传了强制值则按前端。\n                preserve_original_val = request.form.get(\"preserve_original\")\n                if preserve_original_val is None:\n                    preserve_original = True\n                else:\n                    preserve_original = preserve_original_val.strip() == \"1\"\n\n                pixel_art = (request.form.get(\"pixel_art\") or \"1\").strip() == \"1\"\n                req_cols = int(request.form.get(\"cols\") or 0)\n                req_rows = int(request.form.get(\"rows\") or 0)\n                sheet_path, cols, rows, frames, out_fw, out_fh = _animated_to_spritesheet(\n                    src_path,\n                    frame_w,\n                    frame_h,\n                    out_ext=target.suffix.lower(),\n                    preserve_original=preserve_original,\n                    pixel_art=pixel_art,\n                    cols=(req_cols if req_cols > 0 else None),\n                    rows=(req_rows if req_rows > 0 else None),\n                )\n                shutil.move(sheet_path, str(target))\n                st = target.stat()\n                from_type = \"gif\" if ext_name.endswith(\".gif\") else \"webp\"\n                to_type = \"webp_spritesheet\" if target.suffix.lower() == \".webp\" else \"png_spritesheet\"\n                return jsonify({\n                    \"ok\": True,\n                    \"path\": rel_path,\n                    \"size\": st.st_size,\n                    \"backup\": backup,\n                    \"converted\": {\n                        \"from\": from_type,\n                        \"to\": to_type,\n                        \"frame_w\": out_fw,\n                        \"frame_h\": out_fh,\n                        \"columns\": cols,\n                        \"rows\": rows,\n                        \"frames\": frames,\n                        \"preserve_original\": preserve_original,\n                        \"pixel_art\": pixel_art,\n                    }\n                })\n            finally:\n                try:\n                    os.remove(src_path)\n                except Exception:\n                    pass\n\n        f.save(str(target))\n        st = target.stat()\n        return jsonify({\"ok\": True, \"path\": rel_path, \"size\": st.st_size, \"backup\": backup})\n    except Exception as e:\n        return jsonify({\"ok\": False, \"msg\": str(e)}), 500\n\n\nif __name__ == \"__main__\":\n    raw_port = os.environ.get(\"STAR_BACKEND_PORT\", \"19000\")\n    try:\n        backend_port = int(raw_port)\n    except ValueError:\n        backend_port = 19000\n    if backend_port <= 0:\n        backend_port = 19000\n\n    print(\"=\" * 50)\n    print(\"Star Office UI - Backend State Service\")\n    print(\"=\" * 50)\n    print(f\"State file: {STATE_FILE}\")\n    print(f\"Listening on: http://0.0.0.0:{backend_port}\")\n    if backend_port != 19000:\n        print(f\"(Port override: set STAR_BACKEND_PORT to change; current: {raw_port})\")\n    else:\n        print(\"(Set STAR_BACKEND_PORT to use a different port, e.g. 3009)\")\n    mode = \"production\" if is_production_mode() else \"development\"\n    print(f\"Mode: {mode}\")\n    if is_production_mode():\n        print(\"Security hardening: ENABLED (strict checks)\")\n    else:\n        weak_flags = []\n        if not is_strong_secret(str(app.secret_key)):\n            weak_flags.append(\"weak FLASK_SECRET_KEY/STAR_OFFICE_SECRET\")\n        if not is_strong_drawer_pass(ASSET_DRAWER_PASS_DEFAULT):\n            weak_flags.append(\"weak ASSET_DRAWER_PASS\")\n        if weak_flags:\n            print(\"Security hardening: WARNING (dev mode) -> \" + \", \".join(weak_flags))\n        else:\n            print(\"Security hardening: OK\")\n    print(\"=\" * 50)\n\n    app.run(host=\"0.0.0.0\", port=backend_port, debug=False)\n\n"
  },
  {
    "path": "backend/memo_utils.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Memo extraction helpers for Star Office backend.\n\nReads and sanitizes daily memo content from memory/*.md for the yesterday-memo API.\n\"\"\"\n\nfrom __future__ import annotations\n\nfrom datetime import datetime, timedelta\nimport random\nimport re\n\n\ndef get_yesterday_date_str() -> str:\n    \"\"\"Return yesterday's date as YYYY-MM-DD.\"\"\"\n    yesterday = datetime.now() - timedelta(days=1)\n    return yesterday.strftime(\"%Y-%m-%d\")\n\n\ndef sanitize_content(text: str) -> str:\n    \"\"\"Redact PII and sensitive patterns (OpenID, paths, IPs, email, phone) for safe display.\"\"\"\n    text = re.sub(r'ou_[a-f0-9]+', '[用户]', text)\n    text = re.sub(r'user_id=\"[^\"]+\"', 'user_id=\"[隐藏]\"', text)\n    text = re.sub(r'/root/[^\"\\s]+', '[路径]', text)\n    text = re.sub(r'\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}', '[IP]', text)\n\n    text = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}', '[邮箱]', text)\n    text = re.sub(r'1[3-9]\\d{9}', '[手机号]', text)\n\n    return text\n\n\ndef extract_memo_from_file(file_path: str) -> str:\n    \"\"\"Extract display-safe memo text from a memory markdown file; sanitizes and truncates with a short fallback.\"\"\"\n    try:\n        with open(file_path, \"r\", encoding=\"utf-8\") as f:\n            content = f.read()\n\n        # 提取真实内容，不做过度包装\n        lines = content.strip().split(\"\\n\")\n\n        # 提取核心要点\n        core_points = []\n        for line in lines:\n            line = line.strip()\n            if not line:\n                continue\n            if line.startswith(\"#\"):\n                continue\n            if line.startswith(\"- \"):\n                core_points.append(line[2:].strip())\n            elif len(line) > 10:\n                core_points.append(line)\n\n        if not core_points:\n            return \"「昨日无事记录」\\n\\n若有恒，何必三更眠五更起；最无益，莫过一日曝十日寒。\"\n\n        # 从核心内容中提取 2-3 个关键点\n        selected_points = core_points[:3]\n\n        # 睿智语录库\n        wisdom_quotes = [\n            \"「工欲善其事，必先利其器。」\",\n            \"「不积跬步，无以至千里；不积小流，无以成江海。」\",\n            \"「知行合一，方可致远。」\",\n            \"「业精于勤，荒于嬉；行成于思，毁于随。」\",\n            \"「路漫漫其修远兮，吾将上下而求索。」\",\n            \"「昨夜西风凋碧树，独上高楼，望尽天涯路。」\",\n            \"「衣带渐宽终不悔，为伊消得人憔悴。」\",\n            \"「众里寻他千百度，蓦然回首，那人却在，灯火阑珊处。」\",\n            \"「世事洞明皆学问，人情练达即文章。」\",\n            \"「纸上得来终觉浅，绝知此事要躬行。」\"\n        ]\n\n        quote = random.choice(wisdom_quotes)\n\n        # 组合内容\n        result = []\n\n        # 添加核心内容\n        if selected_points:\n            for point in selected_points:\n                # 隐私清理\n                point = sanitize_content(point)\n                # 截断过长的内容\n                if len(point) > 40:\n                    point = point[:37] + \"...\"\n                # 每行最多 20 字\n                if len(point) <= 20:\n                    result.append(f\"· {point}\")\n                else:\n                    # 按 20 字切分\n                    for j in range(0, len(point), 20):\n                        chunk = point[j:j+20]\n                        if j == 0:\n                            result.append(f\"· {chunk}\")\n                        else:\n                            result.append(f\"  {chunk}\")\n\n        # 添加睿智语录\n        if quote:\n            if len(quote) <= 20:\n                result.append(f\"\\n{quote}\")\n            else:\n                for j in range(0, len(quote), 20):\n                    chunk = quote[j:j+20]\n                    if j == 0:\n                        result.append(f\"\\n{chunk}\")\n                    else:\n                        result.append(chunk)\n\n        return \"\\n\".join(result).strip()\n\n    except Exception as e:\n        print(f\"extract_memo_from_file failed: {e}\")\n        return \"「昨日记录加载失败」\\n\\n「往者不可谏，来者犹可追。」\"\n"
  },
  {
    "path": "backend/requirements.txt",
    "content": "flask==3.0.2\npillow==10.4.0\n"
  },
  {
    "path": "backend/run.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\nROOT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")/..\" && pwd)\"\n\n# Auto-load project env file when present.\nif [[ -f \"$ROOT_DIR/.env\" ]]; then\n  set -a\n  # shellcheck disable=SC1091\n  source \"$ROOT_DIR/.env\"\n  set +a\nfi\n\nexec \"$ROOT_DIR/.venv/bin/python\" \"$ROOT_DIR/backend/app.py\"\n"
  },
  {
    "path": "backend/security_utils.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Security helper utilities for Star Office backend.\n\nProduction detection and validation for Flask secret and asset drawer password.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\n\n\ndef is_production_mode() -> bool:\n    \"\"\"Return True if STAR_OFFICE_ENV or FLASK_ENV is prod/production.\"\"\"\n    env = (os.getenv(\"STAR_OFFICE_ENV\") or os.getenv(\"FLASK_ENV\") or \"\").strip().lower()\n    return env in {\"prod\", \"production\"}\n\n\ndef is_strong_secret(secret: str) -> bool:\n    \"\"\"Return True if secret is at least 24 chars and does not contain weak markers (e.g. change-me, dev).\"\"\"\n    if not secret:\n        return False\n    secret = secret.strip()\n    if len(secret) < 24:\n        return False\n    weak_markers = {\"change-me\", \"dev\", \"example\", \"test\", \"default\"}\n    low = secret.lower()\n    return not any(m in low for m in weak_markers)\n\n\ndef is_strong_drawer_pass(pwd: str) -> bool:\n    \"\"\"Return True if password is not default 1234 and has at least 8 characters.\"\"\"\n    if not pwd:\n        return False\n    pwd = pwd.strip()\n    if pwd == \"1234\":\n        return False\n    return len(pwd) >= 8\n"
  },
  {
    "path": "backend/store_utils.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Storage helper utilities for Star Office backend.\n\nJSON load/save for agents state, asset positions/defaults, runtime config, and join keys.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\n\n\ndef _load_json(path: str):\n    \"\"\"Load JSON from a file; caller handles missing file or parse errors.\"\"\"\n    with open(path, \"r\", encoding=\"utf-8\") as f:\n        return json.load(f)\n\n\ndef _save_json(path: str, data):\n    \"\"\"Write data as JSON with UTF-8 and indent=2.\"\"\"\n    with open(path, \"w\", encoding=\"utf-8\") as f:\n        json.dump(data, f, ensure_ascii=False, indent=2)\n\n\ndef load_agents_state(path: str, default_agents: list) -> list:\n    \"\"\"Load agents list from path; return default_agents if file missing or invalid.\"\"\"\n    if os.path.exists(path):\n        try:\n            data = _load_json(path)\n            if isinstance(data, list):\n                return data\n        except Exception:\n            pass\n    return list(default_agents)\n\n\ndef save_agents_state(path: str, agents: list):\n    \"\"\"Persist agents list to path.\"\"\"\n    _save_json(path, agents)\n\n\ndef load_asset_positions(path: str) -> dict:\n    \"\"\"Load asset positions map from path; return {} if missing or invalid.\"\"\"\n    if os.path.exists(path):\n        try:\n            data = _load_json(path)\n            if isinstance(data, dict):\n                return data\n        except Exception:\n            pass\n    return {}\n\n\ndef save_asset_positions(path: str, data: dict):\n    \"\"\"Persist asset positions to path.\"\"\"\n    _save_json(path, data)\n\n\ndef load_asset_defaults(path: str) -> dict:\n    \"\"\"Load asset defaults map from path; return {} if missing or invalid.\"\"\"\n    if os.path.exists(path):\n        try:\n            data = _load_json(path)\n            if isinstance(data, dict):\n                return data\n        except Exception:\n            pass\n    return {}\n\n\ndef save_asset_defaults(path: str, data: dict):\n    \"\"\"Persist asset defaults to path.\"\"\"\n    _save_json(path, data)\n\n\ndef _normalize_user_model(model_name: str) -> str:\n    \"\"\"Map provider model names to canonical user-facing options (nanobanana-pro / nanobanana-2).\"\"\"\n    m = (model_name or \"\").strip().lower()\n    if m in {\"nanobanana-pro\", \"nanobanana-2\"}:\n        return m\n    if m in {\"nano-banana-pro-preview\", \"gemini-3-pro-image-preview\"}:\n        return \"nanobanana-pro\"\n    if m in {\"gemini-2.5-flash-image\", \"gemini-2.0-flash-exp-image-generation\"}:\n        return \"nanobanana-2\"\n    return \"nanobanana-pro\"\n\n\ndef load_runtime_config(path: str) -> dict:\n    \"\"\"Load runtime config (gemini_api_key, gemini_model) from env and optional JSON file.\"\"\"\n    base = {\n        \"gemini_api_key\": os.getenv(\"GEMINI_API_KEY\") or os.getenv(\"GOOGLE_API_KEY\") or \"\",\n        \"gemini_model\": _normalize_user_model(os.getenv(\"GEMINI_MODEL\") or \"nanobanana-pro\"),\n    }\n    if os.path.exists(path):\n        try:\n            data = _load_json(path)\n            if isinstance(data, dict):\n                base.update({k: data.get(k, base.get(k)) for k in [\"gemini_api_key\", \"gemini_model\"]})\n                base[\"gemini_model\"] = _normalize_user_model(base.get(\"gemini_model\") or \"nanobanana-pro\")\n        except Exception:\n            pass\n    return base\n\n\ndef save_runtime_config(path: str, data: dict):\n    \"\"\"Merge data into current runtime config and save to path; chmod 0o600 on path.\"\"\"\n    cfg = load_runtime_config(path)\n    cfg.update(data or {})\n    _save_json(path, cfg)\n    try:\n        os.chmod(path, 0o600)\n    except Exception:\n        pass\n\n\ndef load_join_keys(path: str) -> dict:\n    \"\"\"Load join keys structure from path; return {'keys': []} if missing or invalid.\"\"\"\n    if os.path.exists(path):\n        try:\n            data = _load_json(path)\n            if isinstance(data, dict) and isinstance(data.get(\"keys\"), list):\n                return data\n        except Exception:\n            pass\n    return {\"keys\": []}\n\n\ndef save_join_keys(path: str, data: dict):\n    \"\"\"Persist join keys to path.\"\"\"\n    _save_json(path, data)\n"
  },
  {
    "path": "convert_to_webp.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n批量转换 PNG 资源为 WebP 格式\n- 精灵图使用无损转换\n- 背景图等使用有损转换（质量 85）\n\"\"\"\n\nimport os\nfrom PIL import Image\n\n# 路径\nFRONTEND_DIR = \"/root/.openclaw/workspace/star-office-ui/frontend\"\nSTATIC_DIR = os.path.join(FRONTEND_DIR, \"\")\n\n# 文件分类配置\n# 无损转换：精灵图、需要保持透明精度的\nLOSSLESS_FILES = [\n    \"star-idle-spritesheet.png\",\n    \"star-researching-spritesheet.png\",\n    \"star-working-spritesheet.png\",\n    \"sofa-busy-spritesheet.png\",\n    \"plants-spritesheet.png\",\n    \"posters-spritesheet.png\",\n    \"coffee-machine-spritesheet.png\",\n    \"serverroom-spritesheet.png\"\n]\n\n# 有损转换：背景图等，质量 85\nLOSSY_FILES = [\n    \"office_bg.png\",\n    \"sofa-idle.png\",\n    \"desk.png\"\n]\n\n\ndef convert_to_webp(input_path, output_path, lossless=True, quality=85):\n    \"\"\"转换单个文件为 WebP\"\"\"\n    try:\n        img = Image.open(input_path)\n        \n        # 保存为 WebP\n        if lossless:\n            img.save(output_path, 'WebP', lossless=True, method=6)\n        else:\n            img.save(output_path, 'WebP', quality=quality, method=6)\n        \n        # 计算文件大小\n        orig_size = os.path.getsize(input_path)\n        new_size = os.path.getsize(output_path)\n        savings = (1 - new_size / orig_size) * 100\n        \n        print(f\"✅ {os.path.basename(input_path)} -> {os.path.basename(output_path)}\")\n        print(f\"   原大小: {orig_size/1024:.1f}KB -> 新大小: {new_size/1024:.1f}KB (-{savings:.1f}%)\")\n        \n        return True\n    except Exception as e:\n        print(f\"❌ {os.path.basename(input_path)} 转换失败: {e}\")\n        return False\n\n\ndef main():\n    print(\"=\" * 60)\n    print(\"PNG → WebP 批量转换工具\")\n    print(\"=\" * 60)\n    \n    # 检查目录\n    if not os.path.exists(STATIC_DIR):\n        print(f\"❌ 目录不存在: {STATIC_DIR}\")\n        return\n    \n    success_count = 0\n    fail_count = 0\n    \n    print(\"\\n📁 开始转换...\\n\")\n    \n    # 转换无损文件\n    print(\"--- 无损转换（精灵图）---\")\n    for filename in LOSSLESS_FILES:\n        input_path = os.path.join(STATIC_DIR, filename)\n        if not os.path.exists(input_path):\n            print(f\"⚠️  文件不存在，跳过: {filename}\")\n            continue\n        \n        output_path = os.path.join(STATIC_DIR, filename.replace(\".png\", \".webp\"))\n        if convert_to_webp(input_path, output_path, lossless=True):\n            success_count += 1\n        else:\n            fail_count += 1\n    \n    # 转换有损文件\n    print(\"\\n--- 有损转换（背景图，质量 85）---\")\n    for filename in LOSSY_FILES:\n        input_path = os.path.join(STATIC_DIR, filename)\n        if not os.path.exists(input_path):\n            print(f\"⚠️  文件不存在，跳过: {filename}\")\n            continue\n        \n        output_path = os.path.join(STATIC_DIR, filename.replace(\".png\", \".webp\"))\n        if convert_to_webp(input_path, output_path, lossless=False, quality=85):\n            success_count += 1\n        else:\n            fail_count += 1\n    \n    print(\"\\n\" + \"=\" * 60)\n    print(f\"转换完成！成功: {success_count}, 失败: {fail_count}\")\n    print(\"=\" * 60)\n    print(\"\\n📝 注意:\")\n    print(\"  - PNG 原文件已保留，不会删除\")\n    print(\"  - 需要修改前端代码引用 .webp 文件\")\n    print(\"  - 如需回滚，只需把代码改回引用 .png 即可\")\n\n\nif __name__ == \"__main__\":\n    main()\n\n"
  },
  {
    "path": "desktop-pet/README.md",
    "content": "# Star Office Tauri Desktop Shell\n\n这个目录用于把 `Star-Office-UI` 包成桌面应用（透明窗口），并在启动时自动拉起后端进程。\n\n## 开发运行\n\n先在仓库根目录准备 Python 环境：\n\n```bash\ncd /Users/wangzhaohan/Documents/GitHub/Star-Office-UI\nuv venv .venv\nuv pip install -r backend/requirements.txt --python .venv/bin/python\n```\n\n再启动 Tauri：\n\n```bash\ncd /Users/wangzhaohan/Documents/GitHub/Star-Office-UI/desktop-pet\nnpm install\nnpm run dev\n```\n\n## 自动拉起后端逻辑\n\n- 优先使用：`../.venv/bin/python backend/app.py`\n- 回退到：`python3 backend/app.py`\n- 再回退到：`python backend/app.py`\n\n窗口默认会跳转到：\n\n- `http://127.0.0.1:19000/?desktop=1`\n\n## 可选环境变量\n\n- `STAR_PROJECT_ROOT`：项目根目录（默认会自动探测）\n- `STAR_BACKEND_PYTHON`：自定义 Python 可执行路径\n- `STAR_BACKEND_URL`：自定义桌面窗口打开的 URL\n"
  },
  {
    "path": "desktop-pet/STATE_API.md",
    "content": "# 桌宠状态对接说明（openclaw 用）\n\n桌宠通过读取 **state.json** 获取当前状态并刷新表现（头顶图标/emoji、气泡文案、角色动画、寻路目标）。openclaw 需要**写入或更新**该文件以驱动桌宠。\n\n---\n\n## 1. 文件位置\n\n- **路径**：与桌宠工作目录下的 `state.json`（桌宠启动时会解析项目根目录，即包含 `state.json` 和 `layers/` 的目录）。\n- **格式**：UTF-8 JSON。\n\n---\n\n## 2. state.json 结构\n\n```json\n{\n  \"state\": \"idle\",\n  \"detail\": \"可选，状态说明，目前仅用于展示/调试\",\n  \"progress\": 0.0,\n  \"updated_at\": \"2025-02-27T12:00:00Z\"\n}\n```\n\n| 字段         | 类型    | 必填 | 说明 |\n|--------------|---------|------|------|\n| `state`      | string  | 是   | 当前状态，见下表。桌宠每 ~2s 轮询读取。 |\n| `detail`     | string  | 否   | 可选描述，可被后续扩展用于气泡或调试。 |\n| `progress`   | number  | 否   | 0~1，可选进度，可被后续扩展。 |\n| `updated_at` | string  | 否   | ISO8601 时间，可选。 |\n\n**只有 `state` 会影响桌宠行为**；其余字段可留空或省略。\n\n---\n\n## 3. 状态取值（openclaw 应写入的 `state`）\n\n桌宠只认下面这些**标准状态名**（小写）。写别的值会被当成 `idle` 或按别名映射。\n\n| state 值       | 含义           | 桌宠表现概要 |\n|----------------|----------------|--------------|\n| `idle`         | 摸鱼/无任务     | 💤 呼吸动画，随机闲逛 |\n| `writing`      | 写作/记笔记     | Word 图标，走到 writing POI |\n| `receiving`    | 收消息         | Hangouts 图标，走到 receiving POI |\n| `replying`     | 回复消息       | Glovo 图标，走到 replying POI |\n| `researching`  | 调研/查资料     | Google 图标，走到 researching POI |\n| `executing`    | 执行任务/跑任务 | ⚡ emoji，走到 executing POI |\n| `syncing`      | 同步/备份      | ☁️ emoji，走到 syncing POI |\n| `error`        | 出错           | ❗ emoji，走到 error POI |\n\nPOI 在 `layers/map.json` 的 `pois` 里配置；状态变化时桌宠会寻路到对应格子。\n\n---\n\n## 4. 别名映射（可选）\n\n若 openclaw 侧用不同名字，桌宠前端会先做一次**别名 → 标准状态**的映射，再按上表表现：\n\n| openclaw 可写的 state | 映射为 |\n|------------------------|--------|\n| `working`              | `writing` |\n| `run`                  | `executing` |\n| `running`              | `executing` |\n| `sync`                 | `syncing` |\n| `research`             | `researching` |\n\n未在上述列表中的 `state` 会视为 `idle`。\n\n---\n\n## 5. openclaw 需要“跳”什么\n\n- **写 state.json**：在约定目录下创建/覆盖 `state.json`，保证 `state` 为上面 8 个标准状态之一（或 5 个别名之一）。\n- **何时写**：状态变化时写一次即可；桌宠轮询间隔约 2 秒，无需高频写入。\n- **示例**  \n  - 开始写文档：`{ \"state\": \"writing\" }`  \n  - 收到消息：`{ \"state\": \"receiving\" }`  \n  - 正在回复：`{ \"state\": \"replying\" }`  \n  - 查资料：`{ \"state\": \"researching\" }`  \n  - 执行任务：`{ \"state\": \"executing\" }`  \n  - 同步中：`{ \"state\": \"syncing\" }`  \n  - 出错：`{ \"state\": \"error\" }`  \n  - 摸鱼/无任务：`{ \"state\": \"idle\" }`\n\n按上述方式更新 `state.json`，即可与当前桌宠状态和 POI 行为一致。\n"
  },
  {
    "path": "desktop-pet/package.json",
    "content": "{\n  \"name\": \"star-desktop-pet\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"scripts\": {\n    \"dev\": \"STAR_PROJECT_ROOT=.. tauri dev\",\n    \"build\": \"STAR_PROJECT_ROOT=.. tauri build\",\n    \"tauri\": \"tauri\"\n  },\n  \"devDependencies\": {\n    \"@tauri-apps/cli\": \"^2\"\n  }\n}\n"
  },
  {
    "path": "desktop-pet/src/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <style>\n        @font-face {\n            font-family: 'ipix';\n            src: url('ipix.ttf') format('truetype');\n        }\n        * { margin: 0; padding: 0; }\n        html, body {\n            background: transparent;\n            overflow: hidden;\n            width: 100%;\n            height: 100%;\n            user-select: none;\n            -webkit-user-select: none;\n            font-family: 'ipix', monospace;\n        }\n        canvas { background: transparent !important; position: relative; }\n        #context-menu {\n            display: none;\n            position: fixed;\n            background: rgba(26, 26, 46, 0.95);\n            border: 2px solid #e94560;\n            border-radius: 6px;\n            padding: 4px 0;\n            z-index: 9999;\n            font-family: 'ipix', monospace;\n            font-size: 13px;\n            min-width: 110px;\n            backdrop-filter: blur(8px);\n        }\n        .menu-item { padding: 8px 16px; color: #eee; cursor: pointer; white-space: nowrap; }\n        .menu-item:hover { background: #e94560; }\n        .menu-sep { height: 1px; background: rgba(233,69,96,0.3); margin: 4px 8px; }\n        #bubble-layer {\n            position: absolute;\n            top: 0; left: 0;\n            width: 100%; height: 100%;\n            pointer-events: none;\n            overflow: visible;\n            z-index: 100;\n        }\n        .speech-bubble {\n            position: absolute;\n            background: rgba(255,255,255,0.95);\n            border: 2px solid #888;\n            border-radius: 8px;\n            padding: 6px 14px;\n            font-family: 'ipix', monospace;\n            font-size: 16px;\n            line-height: 1.3;\n            color: #333;\n            white-space: nowrap;\n            opacity: 0;\n            transition: opacity 0.2s;\n            filter: drop-shadow(0 2px 4px rgba(0,0,0,0.18));\n        }\n        .speech-bubble::after {\n            content: '';\n            position: absolute;\n            bottom: -8px;\n            left: 50%;\n            transform: translateX(-50%);\n            width: 0; height: 0;\n            border-left: 6px solid transparent;\n            border-right: 6px solid transparent;\n            border-top: 8px solid #888;\n        }\n        .speech-bubble::before {\n            content: '';\n            position: absolute;\n            bottom: -6px;\n            left: 50%;\n            transform: translateX(-50%);\n            width: 0; height: 0;\n            border-left: 5px solid transparent;\n            border-right: 5px solid transparent;\n            border-top: 7px solid rgba(255,255,255,0.95);\n            z-index: 1;\n        }\n    </style>\n</head>\n<body>\n    <div id=\"bubble-layer\"></div>\n    <div id=\"context-menu\">\n        <div class=\"menu-item\" data-action=\"info\">🏷️ Star 桌宠</div>\n        <div class=\"menu-sep\"></div>\n        <div class=\"menu-item\" data-action=\"quit\">❌ 退出</div>\n    </div>\n\n    <script src=\"https://cdn.jsdelivr.net/npm/phaser@3.80.1/dist/phaser.min.js\"></script>\n    <script>\n    (async function () {\n\n    /* ================================================================\n       §1  Tauri API\n       ================================================================ */\n    const isTauri   = !!window.__TAURI__;\n    const core      = isTauri ? window.__TAURI__.core   : null;\n    const winApi    = isTauri ? window.__TAURI__.window : null;\n    const dpiApi    = isTauri ? window.__TAURI__.dpi    : null;\n    const appWindow = winApi  ? winApi.getCurrentWindow() : null;\n\n    /* ================================================================\n       §2  Context menu & drag\n       ================================================================ */\n    const ctxMenu = document.getElementById('context-menu');\n    document.addEventListener('contextmenu', e => {\n        e.preventDefault();\n        ctxMenu.style.display = 'block';\n        ctxMenu.style.left = Math.min(e.clientX, innerWidth  - 120) + 'px';\n        ctxMenu.style.top  = Math.min(e.clientY, innerHeight -  80) + 'px';\n    });\n    document.addEventListener('click', e => {\n        if (!e.target.closest('#context-menu')) ctxMenu.style.display = 'none';\n    });\n    document.querySelectorAll('.menu-item').forEach(el => {\n        el.addEventListener('click', () => {\n            ctxMenu.style.display = 'none';\n            if (el.dataset.action === 'quit' && appWindow) appWindow.close();\n        });\n    });\n    document.addEventListener('mousedown', e => {\n        if (e.button === 0 && !e.target.closest('#context-menu') && appWindow)\n            appWindow.startDragging();\n    });\n\n    /* ================================================================\n       §3  Load map config from Rust\n       ================================================================ */\n    let map = null;\n    if (core) {\n        try { map = await core.invoke('load_map'); }\n        catch (e) { console.warn('load_map:', e); }\n    }\n    if (!map) {\n        document.body.innerHTML = '<p style=\"color:#fff;padding:20px\">map.json not found</p>';\n        return;\n    }\n\n    const T    = map.tile_size;\n    const COLS = map.cols;\n    const ROWS = map.rows;\n    const ZOOM = map.zoom;\n    const GW   = COLS * T;\n    const GH   = ROWS * T;\n    const BUBBLE_PAD = 40;\n\n    if (appWindow && dpiApi) {\n        try { await appWindow.setSize(new dpiApi.LogicalSize(GW * ZOOM, GH * ZOOM + BUBBLE_PAD)); }\n        catch (_) {}\n    }\n\n    /* ================================================================\n       §4  State definitions\n       ================================================================ */\n    const SPECIAL = new Set([\n        'writing','receiving','replying','researching','executing','syncing','error'\n    ]);\n    const NORM_MAP = {\n        working:'writing', run:'executing', running:'executing',\n        sync:'syncing', research:'researching'\n    };\n    const BUBBLE = {\n        idle:        ['摸鱼中…','有没有新任务？','咖啡真好喝☕','伸个懒腰~'],\n        writing:     ['这个要记下来','写得手酸','再检查一遍✍️'],\n        receiving:   ['有人找我！','看看是什么','来消息了📨'],\n        replying:    ['让我想想…','打字中…','这样回好了💬'],\n        researching: ['让我搜一下🔍','找到线索了','再深挖一点'],\n        executing:   ['冲鸭！🦆','加油加油','马上搞定⚡'],\n        syncing:     ['备份备份☁️','安全第一','同步中…'],\n        error:       ['啊哦…','出问题了❗','马上修好🔧']\n    };\n    const EMOJI = {\n        idle:'💤', writing:'✏️', receiving:'📨', replying:'💬',\n        researching:'🔍', executing:'⚡', syncing:'☁️', error:'❗'\n    };\n\n    /* ================================================================\n       §5  A* pathfinding\n       ================================================================ */\n    function astar(start, goal) {\n        const grid = map.collision;\n        const key  = (r, c) => r * COLS + c;\n        const sk   = key(start.row, start.col);\n        const gk   = key(goal.row, goal.col);\n\n        if (grid[goal.row]?.[goal.col] !== 0) return null;\n        if (sk === gk) return [{ row: goal.row, col: goal.col }];\n\n        const open    = new Set([sk]);\n        const from    = new Map();\n        const gScore  = new Map([[sk, 0]]);\n        const fScore  = new Map([[sk, h(start, goal)]]);\n        const dirs    = [[-1,0],[1,0],[0,-1],[0,1]];\n\n        while (open.size) {\n            let cur = -1, best = Infinity;\n            for (const k of open) {\n                const f = fScore.get(k) ?? Infinity;\n                if (f < best) { best = f; cur = k; }\n            }\n            if (cur === gk) {\n                const path = [];\n                let c = cur;\n                while (c !== undefined) {\n                    path.unshift({ row: Math.floor(c / COLS), col: c % COLS });\n                    c = from.get(c);\n                }\n                return path;\n            }\n            open.delete(cur);\n            const cr = Math.floor(cur / COLS), cc = cur % COLS;\n\n            for (const [dr, dc] of dirs) {\n                const nr = cr + dr, nc = cc + dc;\n                if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;\n                if (grid[nr][nc] !== 0) continue;\n                const nk = key(nr, nc);\n                const tg = (gScore.get(cur) ?? Infinity) + 1;\n                if (tg < (gScore.get(nk) ?? Infinity)) {\n                    from.set(nk, cur);\n                    gScore.set(nk, tg);\n                    fScore.set(nk, tg + h({ row: nr, col: nc }, goal));\n                    open.add(nk);\n                }\n            }\n        }\n        return null;\n    }\n\n    function h(a, b) {\n        return Math.abs(a.row - b.row) + Math.abs(a.col - b.col);\n    }\n\n    /* ================================================================\n       §6  Game globals\n       ================================================================ */\n    let game, star, stateEmoji, stateIcon, shadow;\n    let serverState = 'idle';\n    let charAnim    = 'idle';\n    let charGridR, charGridC;\n    let path        = null;\n    let pathIdx     = 0;\n    let nextBubbleAt = 5000;\n    let lastFetch    = 0;\n\n    const startPoi = map.pois.idle || { row: 5, col: 6 };\n    charGridR = startPoi.row;\n    charGridC = startPoi.col;\n\n    const SPEED = map.character_speed * T;\n\n    function tileX(c) { return c * T + T / 2; }\n    function tileY(r) { return r * T + T / 2; }\n\n    /* ================================================================\n       §7  Phaser game\n       ================================================================ */\n    const phaserGame = new Phaser.Game({\n        type: Phaser.AUTO,\n        width: GW, height: GH,\n        zoom: ZOOM,\n        transparent: true,\n        pixelArt: true,\n        scene: { preload: preloadScene, create: createScene, update: updateScene }\n    });\n    phaserGame.events.on('ready', () => {\n        phaserGame.canvas.style.marginTop = BUBBLE_PAD + 'px';\n    });\n\n    /* ────────── preload ────────── */\n    function preloadScene() {\n        this.load.spritesheet('tiles', map.tileset_url, {\n            frameWidth: T, frameHeight: T\n        });\n        if (map.state_icons && typeof map.state_icons === 'object') {\n            for (const [state, dataUrl] of Object.entries(map.state_icons)) {\n                if (dataUrl) this.load.image('icon_' + state, dataUrl);\n            }\n        }\n    }\n\n    /* ────────── create ────────── */\n    function createScene() {\n        game = this;\n\n        /* ground layer (depth -100) */\n        for (let r = 0; r < ROWS; r++)\n            for (let c = 0; c < COLS; c++) {\n                const id = map.ground[r]?.[c] ?? -1;\n                if (id < 0) continue;\n                game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(-100);\n            }\n\n        /* border layer (topmost, depth 8000) */\n        if (map.border) {\n            for (let r = 0; r < ROWS; r++)\n                for (let c = 0; c < COLS; c++) {\n                    const id = map.border[r]?.[c] ?? -1;\n                    if (id < 0) continue;\n                    game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(8000);\n                }\n        }\n\n        /* rug layer (depth -50, above ground/border, below objects & character) */\n        if (map.rug) {\n            for (let r = 0; r < ROWS; r++)\n                for (let c = 0; c < COLS; c++) {\n                    const id = map.rug[r]?.[c] ?? -1;\n                    if (id < 0) continue;\n                    game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(-50);\n                }\n        }\n\n        /* objects layer (depth = row for Y-sorting) */\n        for (let r = 0; r < ROWS; r++)\n            for (let c = 0; c < COLS; c++) {\n                const id = map.objects[r]?.[c] ?? -1;\n                if (id < 0) continue;\n                game.add.sprite(tileX(c), tileY(r), 'tiles', id).setDepth(r * 10);\n            }\n\n        /* fallback character textures */\n        buildCharTextures();\n        buildCharAnims();\n\n        /* shadow */\n        shadow = game.add.ellipse(0, 0, T * 0.8, T * 0.3, 0x000000, 0.2).setDepth(-1);\n\n        /* character sprite */\n        const sx = tileX(charGridC), sy = tileY(charGridR);\n        star = game.add.sprite(sx, sy, 'cf0').setDepth(charGridR * 10 + 1);\n        star.play('idle');\n\n        /* state indicator: icon (from state_icons) or emoji fallback */\n        stateEmoji = game.add.text(sx + T * 0.6, sy - T * 0.7, '💤', {\n            font: `${Math.round(T * 0.55)}px sans-serif`\n        }).setOrigin(0.5).setDepth(9000);\n\n        const iconScale = (T * 1.1) / 24;\n        const firstIconKey = map.state_icons && Object.keys(map.state_icons)[0];\n        if (firstIconKey && game.textures.exists('icon_' + firstIconKey)) {\n            stateIcon = game.add.sprite(sx + T * 0.6, sy - T * 0.7, 'icon_' + firstIconKey)\n                .setOrigin(0.5).setDepth(9000).setScale(iconScale);\n            stateIcon.setData('baseScale', iconScale);\n            stateIcon.setVisible(false);\n        } else {\n            stateIcon = null;\n        }\n\n        fetchState();\n    }\n\n    /* ────────── update ────────── */\n    function updateScene(time, dt) {\n        if (time - lastFetch > 2000) { fetchState(); lastFetch = time; }\n\n        /* follow path */\n        if (path && pathIdx < path.length) {\n            const wp = path[pathIdx];\n            const tx = tileX(wp.col), ty = tileY(wp.row);\n            const dx = tx - star.x, dy = ty - star.y;\n            const dist = Math.sqrt(dx * dx + dy * dy);\n            const step = SPEED * dt / 1000;\n\n            if (dist <= step + 0.5) {\n                star.x = tx;\n                star.y = ty;\n                charGridR = wp.row;\n                charGridC = wp.col;\n                pathIdx++;\n            } else {\n                star.x += (dx / dist) * step;\n                star.y += (dy / dist) * step;\n            }\n\n            /* pick move animation based on direction */\n            const anim = pickMoveAnim(dx, dy);\n            if (anim !== charAnim) { charAnim = anim; star.play(anim, true); }\n        } else {\n            /* arrived or no path — play state animation */\n            path = null;\n            const anim = SPECIAL.has(serverState) ? serverState : 'idle';\n            if (anim !== charAnim) { charAnim = anim; star.play(anim, true); }\n\n            /* idle wander */\n            if (serverState === 'idle' && Math.random() < 0.003) {\n                const nb = walkableNeighbor(charGridR, charGridC, 3);\n                if (nb) navigateTo(nb.row, nb.col);\n            }\n        }\n\n        /* Y-sort depth */\n        star.setDepth(Math.round(star.y / T) * 10 + 1);\n\n        /* walk wobble */\n        const wobble = path ? Math.sin(time / 120) * 0.4 : 0;\n\n        /* track shadow, state icon / emoji (follow + pulse), bubble */\n        shadow.setPosition(star.x, star.y + T * 0.55 + wobble * 0.3);\n        const stateX = star.x + T * 0.6;\n        const stateY = star.y - T * 0.7 + (path ? wobble * 0.25 : 0);\n        const pulse = 1 + 0.12 * Math.sin(time / 180);\n        stateEmoji.setPosition(stateX, stateY);\n        stateEmoji.setScale(pulse);\n        if (stateIcon) {\n            stateIcon.setPosition(stateX, stateY);\n            const baseScale = stateIcon.getData('baseScale') || (T * 1.1) / 24;\n            stateIcon.setScale(baseScale * pulse);\n        }\n        updateBubblePos();\n\n        /* bubble */\n        if (time > nextBubbleAt) {\n            showBubble();\n            nextBubbleAt = time + 6000 + Math.random() * 4000;\n        }\n    }\n\n    function pickMoveAnim(dx, dy) {\n        if (Math.abs(dx) >= Math.abs(dy))\n            return dx > 0 ? 'move_right' : 'move_left';\n        return dy > 0 ? 'move_down' : 'move_up';\n    }\n\n    function walkableNeighbor(r, c, radius) {\n        const tries = 10;\n        for (let i = 0; i < tries; i++) {\n            const nr = r + Math.round((Math.random() - 0.5) * radius * 2);\n            const nc = c + Math.round((Math.random() - 0.5) * radius * 2);\n            if (nr >= 0 && nr < ROWS && nc >= 0 && nc < COLS\n                && map.collision[nr][nc] === 0 && (nr !== r || nc !== c))\n                return { row: nr, col: nc };\n        }\n        return null;\n    }\n\n    function navigateTo(row, col) {\n        const p = astar({ row: charGridR, col: charGridC }, { row, col });\n        if (p && p.length > 1) {\n            path = p.slice(1);\n            pathIdx = 0;\n        }\n    }\n\n    /* ================================================================\n       §8  Server state polling + POI navigation\n       ================================================================ */\n    let prevServerState = 'idle';\n\n    async function fetchState() {\n        if (!core) return;\n        try {\n            const data = await core.invoke('read_state');\n            const raw  = NORM_MAP[data.state] || data.state || 'idle';\n            serverState = SPECIAL.has(raw) ? raw : 'idle';\n\n            if (serverState !== prevServerState) {\n                prevServerState = serverState;\n                const iconKey = 'icon_' + serverState;\n                if (stateIcon && game.textures.exists(iconKey)) {\n                    stateIcon.setTexture(iconKey).setVisible(true);\n                    stateEmoji.setVisible(false);\n                } else {\n                    if (stateIcon) stateIcon.setVisible(false);\n                    stateEmoji.setVisible(true);\n                    stateEmoji.setText(EMOJI[serverState] || '💤');\n                }\n                const poi = map.pois[serverState];\n                if (poi) navigateTo(poi.row, poi.col);\n            }\n        } catch (_) {}\n    }\n\n    /* ================================================================\n       §9  Character textures (16×16 fallback, 4 directions)\n       ================================================================ */\n    function tex(key, fn) {\n        const g = game.make.graphics();\n        fn(g);\n        g.generateTexture(key, T, T);\n        g.destroy();\n    }\n\n    function buildCharTextures() {\n        tex('cf0',  g => body(g, 'front', true));\n        tex('cf1',  g => body(g, 'front', false));\n        tex('cfw0', g => { body(g, 'front', true); feet(g, 'v', 0); });\n        tex('cfw1', g => { body(g, 'front', true); feet(g, 'v', 1); });\n\n        tex('cb0',  g => body(g, 'back'));\n        tex('cbw0', g => { body(g, 'back'); feet(g, 'v', 0); });\n        tex('cbw1', g => { body(g, 'back'); feet(g, 'v', 1); });\n\n        tex('cl0',  g => body(g, 'left', true));\n        tex('clw0', g => { body(g, 'left', true); feet(g, 'h', 0); });\n        tex('clw1', g => { body(g, 'left', true); feet(g, 'h', 1); });\n\n        tex('cr0',  g => body(g, 'right', true));\n        tex('crw0', g => { body(g, 'right', true); feet(g, 'h', 0); });\n        tex('crw1', g => { body(g, 'right', true); feet(g, 'h', 1); });\n    }\n\n    function body(g, dir, eyesOpen) {\n        const S = T;\n        const bx = Math.round(S * 0.15), by = Math.round(S * 0.1);\n        const bw = S - bx * 2, bh = Math.round(S * 0.8);\n\n        g.fillStyle(0xff6b35);\n        g.fillRect(bx, by, bw, bh);\n        g.fillStyle(0xffb347);\n        g.fillRect(bx + 1, by + 1, bw - 2, 1);\n\n        const ew = Math.max(2, Math.round(S * 0.18));\n        const eh = ew;\n        const ey = by + Math.round(bh * 0.28);\n        const pw = Math.max(1, Math.round(ew * 0.6));\n\n        switch (dir) {\n        case 'front': {\n            const e1x = bx + Math.round(bw * 0.18);\n            const e2x = bx + Math.round(bw * 0.55);\n            if (eyesOpen) {\n                g.fillStyle(0xffffff);\n                g.fillRect(e1x, ey, ew, eh);\n                g.fillRect(e2x, ey, ew, eh);\n                g.fillStyle(0x222222);\n                g.fillRect(e1x + 1, ey + 1, pw, pw);\n                g.fillRect(e2x + 1, ey + 1, pw, pw);\n            } else {\n                g.fillStyle(0x222222);\n                g.fillRect(e1x, ey + Math.round(eh / 2), ew, 1);\n                g.fillRect(e2x, ey + Math.round(eh / 2), ew, 1);\n            }\n            g.fillStyle(0xff8c69);\n            const mw = Math.max(2, Math.round(bw * 0.3));\n            g.fillRect(bx + Math.round((bw - mw) / 2), by + Math.round(bh * 0.7), mw, Math.max(1, Math.round(S * 0.1)));\n            break;\n        }\n        case 'back':\n            g.fillStyle(0xcc5522);\n            g.fillRect(bx + Math.round(bw * 0.25), by + Math.round(bh * 0.2), Math.round(bw * 0.5), 2);\n            break;\n        case 'left': {\n            const ex = bx + Math.round(bw * 0.12);\n            if (eyesOpen) {\n                g.fillStyle(0xffffff);\n                g.fillRect(ex, ey, ew, eh);\n                g.fillStyle(0x222222);\n                g.fillRect(ex, ey + 1, pw, pw);\n            } else {\n                g.fillStyle(0x222222);\n                g.fillRect(ex, ey + Math.round(eh / 2), ew, 1);\n            }\n            g.fillStyle(0xff8c69);\n            g.fillRect(bx, by + Math.round(bh * 0.7), Math.round(bw * 0.3), Math.max(1, Math.round(S * 0.1)));\n            break;\n        }\n        case 'right': {\n            const ex = bx + Math.round(bw * 0.55);\n            if (eyesOpen) {\n                g.fillStyle(0xffffff);\n                g.fillRect(ex, ey, ew, eh);\n                g.fillStyle(0x222222);\n                g.fillRect(ex + ew - pw, ey + 1, pw, pw);\n            } else {\n                g.fillStyle(0x222222);\n                g.fillRect(ex, ey + Math.round(eh / 2), ew, 1);\n            }\n            g.fillStyle(0xff8c69);\n            const mw = Math.round(bw * 0.3);\n            g.fillRect(bx + bw - mw, by + Math.round(bh * 0.7), mw, Math.max(1, Math.round(S * 0.1)));\n            break;\n        }\n        }\n    }\n\n    function feet(g, axis, frame) {\n        const S = T;\n        const bx = Math.round(S * 0.15);\n        const bw = S - bx * 2;\n        const fy = Math.round(S * 0.85);\n        const fw = Math.max(2, Math.round(bw * 0.25));\n        const fh = Math.max(1, Math.round(S * 0.1));\n        g.fillStyle(0xcc5522);\n        if (frame === 0) {\n            g.fillRect(bx + 1, fy, fw, fh);\n        } else {\n            g.fillRect(bx + bw - fw - 1, fy, fw, fh);\n        }\n    }\n\n    /* ================================================================\n       §10  Character animations\n       ================================================================ */\n    function buildCharAnims() {\n        const defs = {\n            idle:       { f: ['cf0','cf0','cf0','cf0','cf0','cf1'], r: 2 },\n            move_down:  { f: ['cfw0','cf0','cfw1','cf0'], r: 6 },\n            move_up:    { f: ['cbw0','cb0','cbw1','cb0'], r: 6 },\n            move_left:  { f: ['clw0','cl0','clw1','cl0'], r: 6 },\n            move_right: { f: ['crw0','cr0','crw1','cr0'], r: 6 },\n            writing:    { f: ['cf0','cf0','cf1','cf0'], r: 2 },\n            receiving:  { f: ['cf0','cf1','cf0','cf1'], r: 3 },\n            replying:   { f: ['cf0','cf0','cf0','cf1'], r: 2 },\n            researching:{ f: ['cf0','cf0','cf1','cf0'], r: 1.5 },\n            executing:  { f: ['cf0','cf1','cf0','cf1'], r: 4 },\n            syncing:    { f: ['cf0','cf0','cf0','cf1'], r: 1 },\n            error:      { f: ['cf1','cf0','cf1','cf1'], r: 2 },\n        };\n        Object.entries(defs).forEach(([k, d]) => {\n            game.anims.create({\n                key: k,\n                frames: d.f.map(f => ({ key: f })),\n                frameRate: d.r,\n                repeat: -1\n            });\n        });\n    }\n\n    /* ================================================================\n       §11  Speech bubble (DOM-based, never clipped by canvas)\n       ================================================================ */\n    const bubbleLayer = document.getElementById('bubble-layer');\n    let bubbleEl = null;\n    let bubbleTimer = null;\n\n    function showBubble() {\n        removeBubble();\n        const pool = BUBBLE[serverState] || BUBBLE.idle;\n        const text = pool[Math.floor(Math.random() * pool.length)];\n\n        bubbleEl = document.createElement('div');\n        bubbleEl.className = 'speech-bubble';\n        bubbleEl.textContent = text;\n        bubbleLayer.appendChild(bubbleEl);\n\n        updateBubblePos();\n        requestAnimationFrame(() => { if (bubbleEl) bubbleEl.style.opacity = '1'; });\n\n        bubbleTimer = setTimeout(() => {\n            if (bubbleEl) bubbleEl.style.opacity = '0';\n            setTimeout(removeBubble, 300);\n        }, 3500);\n    }\n\n    function removeBubble() {\n        if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null; }\n        if (bubbleEl) { bubbleEl.remove(); bubbleEl = null; }\n    }\n\n    function updateBubblePos() {\n        if (!bubbleEl || !star) return;\n        const winW = window.innerWidth;\n        const bw = bubbleEl.offsetWidth  || 80;\n        const bh = bubbleEl.offsetHeight || 30;\n\n        const cx = star.x * ZOOM;\n        const cy = star.y * ZOOM + BUBBLE_PAD;\n\n        let x = cx - bw / 2;\n        let y = cy - T * ZOOM * 0.8 - bh;\n\n        x = Math.max(4, Math.min(x, winW - bw - 4));\n        y = Math.max(2, y);\n\n        bubbleEl.style.left = x + 'px';\n        bubbleEl.style.top  = y + 'px';\n    }\n\n    })();\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "desktop-pet/src/minimized.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n  <meta charset=\"UTF-8\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n  <title>Star Mini</title>\n  <style>\n    * { box-sizing: border-box; margin: 0; padding: 0; }\n    :root { --mini-status-sprite-gap: 0px; }\n    html, body {\n      width: 100%;\n      height: 100%;\n      background: transparent;\n      overflow: hidden;\n      user-select: none;\n      -webkit-user-select: none;\n      font-family: Arial, sans-serif;\n    }\n    body.electron-shell,\n    body.electron-shell #wrap,\n    body.electron-shell #pet-box {\n      -webkit-app-region: drag;\n    }\n    body.electron-shell #status-pill,\n    body.electron-shell #pet-canvas {\n      -webkit-app-region: no-drag;\n    }\n    #wrap {\n      width: 100%;\n      height: 100%;\n      position: relative;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: flex-start;\n      padding-top: 8px;\n      gap: var(--mini-status-sprite-gap);\n    }\n    #status-pill {\n      max-width: 95%;\n      padding: 6px 10px;\n      border-radius: 8px;\n      background: rgba(0, 0, 0, 0.72);\n      color: #eee;\n      font-size: 12px;\n      white-space: nowrap;\n      overflow: hidden;\n      text-overflow: ellipsis;\n      pointer-events: none;\n    }\n    #pet-box {\n      width: 180px;\n      height: 180px;\n      border-radius: 6px;\n      background: transparent;\n      display: flex;\n      align-items: center;\n      justify-content: center;\n      cursor: pointer;\n      transition: transform 0.14s ease, filter 0.18s ease;\n    }\n    #pet-canvas {\n      width: 140px;\n      height: 140px;\n      image-rendering: pixelated;\n      pointer-events: none;\n      transform: scale(1);\n      filter: drop-shadow(0 0 0 rgba(250, 244, 207, 0));\n      transition: transform 0.16s ease, filter 0.2s ease;\n    }\n    #pet-box:hover {\n      transform: translateY(-2px);\n      filter: brightness(1.04);\n    }\n    #pet-box:hover #pet-canvas {\n      filter: drop-shadow(0 0 8px rgba(250, 244, 207, 0.2));\n    }\n    #pet-box:active {\n      transform: translateY(0);\n      filter: brightness(0.98);\n    }\n    #hint { display: none; }\n  </style>\n</head>\n<body>\n  <div id=\"wrap\">\n    <div id=\"status-pill\">加载中...</div>\n    <div id=\"pet-box\" title=\"点击恢复主窗口\">\n      <canvas id=\"pet-canvas\" width=\"140\" height=\"140\" aria-label=\"Star\"></canvas>\n    </div>\n    <div id=\"hint\">点击形象恢复主窗口</div>\n  </div>\n  <script>\n    (async function () {\n      const isTauri = !!window.__TAURI__;\n      const isElectron = !!window.__ELECTRON__;\n      const core = isTauri ? window.__TAURI__.core : null;\n      const eventApi = isTauri ? window.__TAURI__.event : null;\n      const win = isTauri ? window.__TAURI__.window.getCurrentWindow() : null;\n      const status = document.getElementById('status-pill');\n      const petCanvas = document.getElementById('pet-canvas');\n      const petCtx = petCanvas && petCanvas.getContext ? petCanvas.getContext('2d') : null;\n      if (isElectron) document.body.classList.add('electron-shell');\n\n      const BASE_URL = 'http://127.0.0.1:19000';\n      const STATIC_URL = `${BASE_URL}/static/`;\n      let uiLang = 'en';\n      const I18N = {\n        zh: { stateIdle: '待命', stateWriting: '整理文档', stateResearching: '搜索信息', stateExecuting: '执行任务', stateSyncing: '同步备份', stateError: '出错了', fallbackIdleDetail: '待命', connecting: '连接中...' },\n        en: { stateIdle: 'Standby', stateWriting: 'Organizing Docs', stateResearching: 'Researching', stateExecuting: 'Executing Tasks', stateSyncing: 'Syncing Backup', stateError: 'Error', fallbackIdleDetail: 'Standby', connecting: 'Connecting...' },\n        ja: { stateIdle: '待機', stateWriting: '文書整理', stateResearching: '情報検索', stateExecuting: 'タスク実行', stateSyncing: '同期バックアップ', stateError: 'エラー発生', fallbackIdleDetail: '待機', connecting: '接続中...' }\n      };\n      const t = (key) => ((I18N[uiLang] && I18N[uiLang][key]) || key);\n\n      // Keep exactly aligned with main page asset names.\n      const PET_ASSET_PATHS = {\n        idle: 'star-idle-v5.png',\n        working: 'star-working-spritesheet-grid.webp',\n        syncing: 'sync-animation-v3-grid.webp',\n        error: 'error-bug-spritesheet-grid.webp'\n      };\n      const PET_FRAME_CONFIG = {\n        idle: { frameW: 256, frameH: 256, fps: 12 },\n        working: { frameW: 300, frameH: 300, fps: 12 },\n        syncing: { frameW: 256, frameH: 256, fps: 12 },\n        error: { frameW: 220, frameH: 220, fps: 12 }\n      };\n      // Align perceived sprite size with main page defaults.\n      const PET_SCALE = { idle: 1.2, working: 1.4, syncing: 1.2, error: 1.2 };\n\n      let assetRefreshTick = Date.now();\n      let lastAssetKey = null;\n      let currentSpriteSrc = '';\n      let spriteImage = null;\n      let spriteMeta = { frameW: 1, frameH: 1, fps: 1, start: 0, end: 0, frames: 1 };\n      let currentFrame = 0;\n      let lastFrameAt = 0;\n      let rafId = null;\n      window.__miniLastState = { state: 'idle' };\n\n      function normalizeLang(rawLang) {\n        const v = String(rawLang || '').toLowerCase();\n        if (v === 'zh' || v === 'en' || v === 'ja') return v;\n        return 'en';\n      }\n      function normalizeState(state) {\n        if (!state) return 'idle';\n        if (state === 'working' || state === 'writing' || state === 'researching' || state === 'executing') return 'working';\n        if (state === 'sync' || state === 'syncing') return 'syncing';\n        if (state === 'error') return 'error';\n        return 'idle';\n      }\n      function stateLabel(rawState) {\n        const s = String(rawState || '').toLowerCase();\n        if (s === 'writing' || s === 'working') return t('stateWriting');\n        if (s === 'researching') return t('stateResearching');\n        if (s === 'executing' || s === 'run' || s === 'running') return t('stateExecuting');\n        if (s === 'syncing' || s === 'sync') return t('stateSyncing');\n        if (s === 'error') return t('stateError');\n        return t('stateIdle');\n      }\n      function buildPetSrcByState(rawState) {\n        const key = normalizeState(rawState);\n        const rel = PET_ASSET_PATHS[key] || PET_ASSET_PATHS.idle;\n        return { key, src: `${STATIC_URL}${rel}?v=${assetRefreshTick}` };\n      }\n      function resolveFrameRangeByState(stateKey, totalFrames) {\n        const maxIdx = Math.max(0, totalFrames - 1);\n        if (stateKey === 'working') {\n          return { start: 0, end: Math.min(37, maxIdx) };\n        }\n        if (stateKey === 'error') {\n          return { start: 0, end: Math.min(71, maxIdx) };\n        }\n        if (stateKey === 'syncing') {\n          if (totalFrames >= 3) {\n            const start = 1;\n            const end = Math.max(start, totalFrames - 2);\n            return { start, end };\n          }\n          return { start: 0, end: 0 };\n        }\n        // idle: use full available frames\n        return { start: 0, end: maxIdx };\n      }\n\n      function drawFrame() {\n        if (!petCtx || !spriteImage || !spriteMeta.frames) return;\n        const cols = Math.max(1, Math.floor(spriteImage.naturalWidth / spriteMeta.frameW));\n        const frame = spriteMeta.start + (currentFrame % spriteMeta.frames);\n        const sx = (frame % cols) * spriteMeta.frameW;\n        const sy = Math.floor(frame / cols) * spriteMeta.frameH;\n        petCtx.clearRect(0, 0, petCanvas.width, petCanvas.height);\n        petCtx.imageSmoothingEnabled = false;\n        petCtx.drawImage(\n          spriteImage,\n          sx, sy, spriteMeta.frameW, spriteMeta.frameH,\n          0, 0, petCanvas.width, petCanvas.height\n        );\n      }\n      function stopSpriteLoop() {\n        if (rafId) cancelAnimationFrame(rafId);\n        rafId = null;\n      }\n      function startSpriteLoop() {\n        stopSpriteLoop();\n        const tick = (ts) => {\n          if (!spriteImage || !spriteMeta.frames) return;\n          const interval = 1000 / Math.max(1, spriteMeta.fps || 1);\n          if (!lastFrameAt || ts - lastFrameAt >= interval) {\n            currentFrame = (currentFrame + 1) % Math.max(1, spriteMeta.frames);\n            drawFrame();\n            lastFrameAt = ts;\n          }\n          rafId = requestAnimationFrame(tick);\n        };\n        rafId = requestAnimationFrame(tick);\n      }\n\n      function loadSpriteByState(rawState) {\n        const next = buildPetSrcByState(rawState);\n        const cfg = PET_FRAME_CONFIG[next.key] || PET_FRAME_CONFIG.idle;\n        if (currentSpriteSrc === next.src && lastAssetKey === next.key) return;\n\n        const img = new Image();\n        img.onload = () => {\n          spriteImage = img;\n          const cols = Math.max(1, Math.floor(img.naturalWidth / cfg.frameW));\n          const rows = Math.max(1, Math.floor(img.naturalHeight / cfg.frameH));\n          const totalFrames = Math.max(1, cols * rows);\n          const range = resolveFrameRangeByState(next.key, totalFrames);\n          const frames = Math.max(1, range.end - range.start + 1);\n          spriteMeta = {\n            frameW: cfg.frameW,\n            frameH: cfg.frameH,\n            fps: cfg.fps,\n            start: range.start,\n            end: range.end,\n            frames\n          };\n          currentFrame = 0;\n          lastFrameAt = 0;\n          drawFrame();\n          if (frames > 1) startSpriteLoop();\n          else stopSpriteLoop();\n          currentSpriteSrc = next.src;\n          lastAssetKey = next.key;\n        };\n        img.onerror = () => {\n          if (next.key !== 'idle') {\n            currentSpriteSrc = '';\n            lastAssetKey = null;\n            loadSpriteByState('idle');\n          }\n        };\n        img.src = next.src;\n      }\n\n      function applyState(data, instant = false) {\n        const payload = data || { state: 'idle' };\n        uiLang = normalizeLang(payload.ui_lang || uiLang);\n        window.__miniLastState = payload;\n        const state = payload.state || 'idle';\n        const detail = payload.detail || t('fallbackIdleDetail');\n        status.textContent = `[${stateLabel(state)}] ${detail}`;\n        loadSpriteByState(state);\n\n        const scale = PET_SCALE[normalizeState(state)] || 1;\n        if (instant) {\n          const prevTransition = petCanvas.style.transition;\n          petCanvas.style.transition = 'none';\n          petCanvas.style.transform = `scale(${scale})`;\n          requestAnimationFrame(() => {\n            petCanvas.style.transition = prevTransition || 'transform 0.16s ease, filter 0.2s ease';\n          });\n        } else {\n          petCanvas.style.transform = `scale(${scale})`;\n        }\n      }\n\n      async function fetchStatus() {\n        try {\n          if (core) {\n            const data = await core.invoke('read_state');\n            applyState(data);\n            return;\n          }\n          const resp = await fetch(`${BASE_URL}/status`, { cache: 'no-store' });\n          if (!resp.ok) throw new Error('bad status');\n          const data = await resp.json();\n          applyState(data);\n        } catch (_) {\n          status.textContent = t('connecting');\n        }\n      }\n\n      if (eventApi && eventApi.listen) {\n        try {\n          await eventApi.listen('mini-sync-state', (evt) => {\n            if (evt && evt.payload) applyState(evt.payload, true);\n          });\n        } catch (_) {}\n      }\n\n      let downAt = null;\n      let dragTriggered = false;\n      const DRAG_THRESHOLD = 6;\n      document.addEventListener('pointerdown', (e) => {\n        if (e.button !== 0) return;\n        downAt = { x: e.clientX, y: e.clientY };\n        dragTriggered = false;\n      });\n      document.addEventListener('pointermove', async (e) => {\n        if (!downAt || dragTriggered || !win) return;\n        const moved = Math.hypot(e.clientX - downAt.x, e.clientY - downAt.y);\n        if (moved < DRAG_THRESHOLD) return;\n        dragTriggered = true;\n        try { await win.startDragging(); } catch (_) {}\n      });\n      document.addEventListener('pointerup', async () => {\n        if (!downAt) return;\n        const wasDrag = dragTriggered;\n        downAt = null;\n        dragTriggered = false;\n        if (!wasDrag && core) {\n          try { await core.invoke('restore_main_window'); } catch (_) {}\n        }\n      });\n      document.addEventListener('contextmenu', async (e) => {\n        e.preventDefault();\n        if (!core) return;\n        try { await core.invoke('close_app'); } catch (_) {}\n      });\n\n      await fetchStatus();\n      setInterval(fetchStatus, 2000);\n    })();\n  </script>\n</body>\n</html>\n"
  },
  {
    "path": "desktop-pet/src-tauri/Cargo.toml",
    "content": "[package]\nname = \"star-desktop-pet\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"star_desktop_pet_lib\"\ncrate-type = [\"lib\"]\n\n[build-dependencies]\ntauri-build = { version = \"2\", features = [] }\n\n[dependencies]\ntauri = { version = \"2\", features = [\"macos-private-api\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nbase64 = \"0.22\"\n"
  },
  {
    "path": "desktop-pet/src-tauri/build.rs",
    "content": "fn main() {\n    tauri_build::build()\n}\n"
  },
  {
    "path": "desktop-pet/src-tauri/capabilities/default.json",
    "content": "{\n  \"identifier\": \"default\",\n  \"description\": \"Default capabilities for the desktop pet\",\n  \"windows\": [\"main\", \"mini\"],\n  \"remote\": {\n    \"urls\": [\n      \"http://127.0.0.1:*\",\n      \"http://localhost:*\"\n    ]\n  },\n  \"permissions\": [\n    \"core:default\",\n    \"core:window:allow-start-dragging\",\n    \"core:window:allow-close\",\n    \"core:window:allow-set-size\"\n  ]\n}\n"
  },
  {
    "path": "desktop-pet/src-tauri/gen/schemas/acl-manifests.json",
    "content": "{\"core\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default core plugins set.\",\"permissions\":[\"core:path:default\",\"core:event:default\",\"core:window:default\",\"core:webview:default\",\"core:app:default\",\"core:image:default\",\"core:resources:default\",\"core:menu:default\",\"core:tray:default\"]},\"permissions\":{},\"permission_sets\":{},\"global_scope_schema\":null},\"core:app\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-version\",\"allow-name\",\"allow-tauri-version\",\"allow-identifier\",\"allow-bundle-type\",\"allow-register-listener\",\"allow-remove-listener\"]},\"permissions\":{\"allow-app-hide\":{\"identifier\":\"allow-app-hide\",\"description\":\"Enables the app_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"app_hide\"],\"deny\":[]}},\"allow-app-show\":{\"identifier\":\"allow-app-show\",\"description\":\"Enables the app_show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"app_show\"],\"deny\":[]}},\"allow-bundle-type\":{\"identifier\":\"allow-bundle-type\",\"description\":\"Enables the bundle_type command without any pre-configured scope.\",\"commands\":{\"allow\":[\"bundle_type\"],\"deny\":[]}},\"allow-default-window-icon\":{\"identifier\":\"allow-default-window-icon\",\"description\":\"Enables the default_window_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"default_window_icon\"],\"deny\":[]}},\"allow-fetch-data-store-identifiers\":{\"identifier\":\"allow-fetch-data-store-identifiers\",\"description\":\"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\"commands\":{\"allow\":[\"fetch_data_store_identifiers\"],\"deny\":[]}},\"allow-identifier\":{\"identifier\":\"allow-identifier\",\"description\":\"Enables the identifier command without any pre-configured scope.\",\"commands\":{\"allow\":[\"identifier\"],\"deny\":[]}},\"allow-name\":{\"identifier\":\"allow-name\",\"description\":\"Enables the name command without any pre-configured scope.\",\"commands\":{\"allow\":[\"name\"],\"deny\":[]}},\"allow-register-listener\":{\"identifier\":\"allow-register-listener\",\"description\":\"Enables the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[\"register_listener\"],\"deny\":[]}},\"allow-remove-data-store\":{\"identifier\":\"allow-remove-data-store\",\"description\":\"Enables the remove_data_store command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_data_store\"],\"deny\":[]}},\"allow-remove-listener\":{\"identifier\":\"allow-remove-listener\",\"description\":\"Enables the remove_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_listener\"],\"deny\":[]}},\"allow-set-app-theme\":{\"identifier\":\"allow-set-app-theme\",\"description\":\"Enables the set_app_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_app_theme\"],\"deny\":[]}},\"allow-set-dock-visibility\":{\"identifier\":\"allow-set-dock-visibility\",\"description\":\"Enables the set_dock_visibility command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_dock_visibility\"],\"deny\":[]}},\"allow-tauri-version\":{\"identifier\":\"allow-tauri-version\",\"description\":\"Enables the tauri_version command without any pre-configured scope.\",\"commands\":{\"allow\":[\"tauri_version\"],\"deny\":[]}},\"allow-version\":{\"identifier\":\"allow-version\",\"description\":\"Enables the version command without any pre-configured scope.\",\"commands\":{\"allow\":[\"version\"],\"deny\":[]}},\"deny-app-hide\":{\"identifier\":\"deny-app-hide\",\"description\":\"Denies the app_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"app_hide\"]}},\"deny-app-show\":{\"identifier\":\"deny-app-show\",\"description\":\"Denies the app_show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"app_show\"]}},\"deny-bundle-type\":{\"identifier\":\"deny-bundle-type\",\"description\":\"Denies the bundle_type command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"bundle_type\"]}},\"deny-default-window-icon\":{\"identifier\":\"deny-default-window-icon\",\"description\":\"Denies the default_window_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"default_window_icon\"]}},\"deny-fetch-data-store-identifiers\":{\"identifier\":\"deny-fetch-data-store-identifiers\",\"description\":\"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"fetch_data_store_identifiers\"]}},\"deny-identifier\":{\"identifier\":\"deny-identifier\",\"description\":\"Denies the identifier command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"identifier\"]}},\"deny-name\":{\"identifier\":\"deny-name\",\"description\":\"Denies the name command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"name\"]}},\"deny-register-listener\":{\"identifier\":\"deny-register-listener\",\"description\":\"Denies the register_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"register_listener\"]}},\"deny-remove-data-store\":{\"identifier\":\"deny-remove-data-store\",\"description\":\"Denies the remove_data_store command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_data_store\"]}},\"deny-remove-listener\":{\"identifier\":\"deny-remove-listener\",\"description\":\"Denies the remove_listener command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_listener\"]}},\"deny-set-app-theme\":{\"identifier\":\"deny-set-app-theme\",\"description\":\"Denies the set_app_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_app_theme\"]}},\"deny-set-dock-visibility\":{\"identifier\":\"deny-set-dock-visibility\",\"description\":\"Denies the set_dock_visibility command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_dock_visibility\"]}},\"deny-tauri-version\":{\"identifier\":\"deny-tauri-version\",\"description\":\"Denies the tauri_version command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"tauri_version\"]}},\"deny-version\":{\"identifier\":\"deny-version\",\"description\":\"Denies the version command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"version\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:event\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-listen\",\"allow-unlisten\",\"allow-emit\",\"allow-emit-to\"]},\"permissions\":{\"allow-emit\":{\"identifier\":\"allow-emit\",\"description\":\"Enables the emit command without any pre-configured scope.\",\"commands\":{\"allow\":[\"emit\"],\"deny\":[]}},\"allow-emit-to\":{\"identifier\":\"allow-emit-to\",\"description\":\"Enables the emit_to command without any pre-configured scope.\",\"commands\":{\"allow\":[\"emit_to\"],\"deny\":[]}},\"allow-listen\":{\"identifier\":\"allow-listen\",\"description\":\"Enables the listen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"listen\"],\"deny\":[]}},\"allow-unlisten\":{\"identifier\":\"allow-unlisten\",\"description\":\"Enables the unlisten command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unlisten\"],\"deny\":[]}},\"deny-emit\":{\"identifier\":\"deny-emit\",\"description\":\"Denies the emit command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"emit\"]}},\"deny-emit-to\":{\"identifier\":\"deny-emit-to\",\"description\":\"Denies the emit_to command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"emit_to\"]}},\"deny-listen\":{\"identifier\":\"deny-listen\",\"description\":\"Denies the listen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"listen\"]}},\"deny-unlisten\":{\"identifier\":\"deny-unlisten\",\"description\":\"Denies the unlisten command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unlisten\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:image\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-from-bytes\",\"allow-from-path\",\"allow-rgba\",\"allow-size\"]},\"permissions\":{\"allow-from-bytes\":{\"identifier\":\"allow-from-bytes\",\"description\":\"Enables the from_bytes command without any pre-configured scope.\",\"commands\":{\"allow\":[\"from_bytes\"],\"deny\":[]}},\"allow-from-path\":{\"identifier\":\"allow-from-path\",\"description\":\"Enables the from_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"from_path\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-rgba\":{\"identifier\":\"allow-rgba\",\"description\":\"Enables the rgba command without any pre-configured scope.\",\"commands\":{\"allow\":[\"rgba\"],\"deny\":[]}},\"allow-size\":{\"identifier\":\"allow-size\",\"description\":\"Enables the size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"size\"],\"deny\":[]}},\"deny-from-bytes\":{\"identifier\":\"deny-from-bytes\",\"description\":\"Denies the from_bytes command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"from_bytes\"]}},\"deny-from-path\":{\"identifier\":\"deny-from-path\",\"description\":\"Denies the from_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"from_path\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-rgba\":{\"identifier\":\"deny-rgba\",\"description\":\"Denies the rgba command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"rgba\"]}},\"deny-size\":{\"identifier\":\"deny-size\",\"description\":\"Denies the size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"size\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:menu\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-append\",\"allow-prepend\",\"allow-insert\",\"allow-remove\",\"allow-remove-at\",\"allow-items\",\"allow-get\",\"allow-popup\",\"allow-create-default\",\"allow-set-as-app-menu\",\"allow-set-as-window-menu\",\"allow-text\",\"allow-set-text\",\"allow-is-enabled\",\"allow-set-enabled\",\"allow-set-accelerator\",\"allow-set-as-windows-menu-for-nsapp\",\"allow-set-as-help-menu-for-nsapp\",\"allow-is-checked\",\"allow-set-checked\",\"allow-set-icon\"]},\"permissions\":{\"allow-append\":{\"identifier\":\"allow-append\",\"description\":\"Enables the append command without any pre-configured scope.\",\"commands\":{\"allow\":[\"append\"],\"deny\":[]}},\"allow-create-default\":{\"identifier\":\"allow-create-default\",\"description\":\"Enables the create_default command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_default\"],\"deny\":[]}},\"allow-get\":{\"identifier\":\"allow-get\",\"description\":\"Enables the get command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get\"],\"deny\":[]}},\"allow-insert\":{\"identifier\":\"allow-insert\",\"description\":\"Enables the insert command without any pre-configured scope.\",\"commands\":{\"allow\":[\"insert\"],\"deny\":[]}},\"allow-is-checked\":{\"identifier\":\"allow-is-checked\",\"description\":\"Enables the is_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_checked\"],\"deny\":[]}},\"allow-is-enabled\":{\"identifier\":\"allow-is-enabled\",\"description\":\"Enables the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_enabled\"],\"deny\":[]}},\"allow-items\":{\"identifier\":\"allow-items\",\"description\":\"Enables the items command without any pre-configured scope.\",\"commands\":{\"allow\":[\"items\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-popup\":{\"identifier\":\"allow-popup\",\"description\":\"Enables the popup command without any pre-configured scope.\",\"commands\":{\"allow\":[\"popup\"],\"deny\":[]}},\"allow-prepend\":{\"identifier\":\"allow-prepend\",\"description\":\"Enables the prepend command without any pre-configured scope.\",\"commands\":{\"allow\":[\"prepend\"],\"deny\":[]}},\"allow-remove\":{\"identifier\":\"allow-remove\",\"description\":\"Enables the remove command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove\"],\"deny\":[]}},\"allow-remove-at\":{\"identifier\":\"allow-remove-at\",\"description\":\"Enables the remove_at command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_at\"],\"deny\":[]}},\"allow-set-accelerator\":{\"identifier\":\"allow-set-accelerator\",\"description\":\"Enables the set_accelerator command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_accelerator\"],\"deny\":[]}},\"allow-set-as-app-menu\":{\"identifier\":\"allow-set-as-app-menu\",\"description\":\"Enables the set_as_app_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_app_menu\"],\"deny\":[]}},\"allow-set-as-help-menu-for-nsapp\":{\"identifier\":\"allow-set-as-help-menu-for-nsapp\",\"description\":\"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_help_menu_for_nsapp\"],\"deny\":[]}},\"allow-set-as-window-menu\":{\"identifier\":\"allow-set-as-window-menu\",\"description\":\"Enables the set_as_window_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_window_menu\"],\"deny\":[]}},\"allow-set-as-windows-menu-for-nsapp\":{\"identifier\":\"allow-set-as-windows-menu-for-nsapp\",\"description\":\"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_as_windows_menu_for_nsapp\"],\"deny\":[]}},\"allow-set-checked\":{\"identifier\":\"allow-set-checked\",\"description\":\"Enables the set_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_checked\"],\"deny\":[]}},\"allow-set-enabled\":{\"identifier\":\"allow-set-enabled\",\"description\":\"Enables the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_enabled\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-text\":{\"identifier\":\"allow-set-text\",\"description\":\"Enables the set_text command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_text\"],\"deny\":[]}},\"allow-text\":{\"identifier\":\"allow-text\",\"description\":\"Enables the text command without any pre-configured scope.\",\"commands\":{\"allow\":[\"text\"],\"deny\":[]}},\"deny-append\":{\"identifier\":\"deny-append\",\"description\":\"Denies the append command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"append\"]}},\"deny-create-default\":{\"identifier\":\"deny-create-default\",\"description\":\"Denies the create_default command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_default\"]}},\"deny-get\":{\"identifier\":\"deny-get\",\"description\":\"Denies the get command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get\"]}},\"deny-insert\":{\"identifier\":\"deny-insert\",\"description\":\"Denies the insert command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"insert\"]}},\"deny-is-checked\":{\"identifier\":\"deny-is-checked\",\"description\":\"Denies the is_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_checked\"]}},\"deny-is-enabled\":{\"identifier\":\"deny-is-enabled\",\"description\":\"Denies the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_enabled\"]}},\"deny-items\":{\"identifier\":\"deny-items\",\"description\":\"Denies the items command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"items\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-popup\":{\"identifier\":\"deny-popup\",\"description\":\"Denies the popup command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"popup\"]}},\"deny-prepend\":{\"identifier\":\"deny-prepend\",\"description\":\"Denies the prepend command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"prepend\"]}},\"deny-remove\":{\"identifier\":\"deny-remove\",\"description\":\"Denies the remove command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove\"]}},\"deny-remove-at\":{\"identifier\":\"deny-remove-at\",\"description\":\"Denies the remove_at command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_at\"]}},\"deny-set-accelerator\":{\"identifier\":\"deny-set-accelerator\",\"description\":\"Denies the set_accelerator command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_accelerator\"]}},\"deny-set-as-app-menu\":{\"identifier\":\"deny-set-as-app-menu\",\"description\":\"Denies the set_as_app_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_app_menu\"]}},\"deny-set-as-help-menu-for-nsapp\":{\"identifier\":\"deny-set-as-help-menu-for-nsapp\",\"description\":\"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_help_menu_for_nsapp\"]}},\"deny-set-as-window-menu\":{\"identifier\":\"deny-set-as-window-menu\",\"description\":\"Denies the set_as_window_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_window_menu\"]}},\"deny-set-as-windows-menu-for-nsapp\":{\"identifier\":\"deny-set-as-windows-menu-for-nsapp\",\"description\":\"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_as_windows_menu_for_nsapp\"]}},\"deny-set-checked\":{\"identifier\":\"deny-set-checked\",\"description\":\"Denies the set_checked command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_checked\"]}},\"deny-set-enabled\":{\"identifier\":\"deny-set-enabled\",\"description\":\"Denies the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_enabled\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-text\":{\"identifier\":\"deny-set-text\",\"description\":\"Denies the set_text command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_text\"]}},\"deny-text\":{\"identifier\":\"deny-text\",\"description\":\"Denies the text command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"text\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:path\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-resolve-directory\",\"allow-resolve\",\"allow-normalize\",\"allow-join\",\"allow-dirname\",\"allow-extname\",\"allow-basename\",\"allow-is-absolute\"]},\"permissions\":{\"allow-basename\":{\"identifier\":\"allow-basename\",\"description\":\"Enables the basename command without any pre-configured scope.\",\"commands\":{\"allow\":[\"basename\"],\"deny\":[]}},\"allow-dirname\":{\"identifier\":\"allow-dirname\",\"description\":\"Enables the dirname command without any pre-configured scope.\",\"commands\":{\"allow\":[\"dirname\"],\"deny\":[]}},\"allow-extname\":{\"identifier\":\"allow-extname\",\"description\":\"Enables the extname command without any pre-configured scope.\",\"commands\":{\"allow\":[\"extname\"],\"deny\":[]}},\"allow-is-absolute\":{\"identifier\":\"allow-is-absolute\",\"description\":\"Enables the is_absolute command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_absolute\"],\"deny\":[]}},\"allow-join\":{\"identifier\":\"allow-join\",\"description\":\"Enables the join command without any pre-configured scope.\",\"commands\":{\"allow\":[\"join\"],\"deny\":[]}},\"allow-normalize\":{\"identifier\":\"allow-normalize\",\"description\":\"Enables the normalize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"normalize\"],\"deny\":[]}},\"allow-resolve\":{\"identifier\":\"allow-resolve\",\"description\":\"Enables the resolve command without any pre-configured scope.\",\"commands\":{\"allow\":[\"resolve\"],\"deny\":[]}},\"allow-resolve-directory\":{\"identifier\":\"allow-resolve-directory\",\"description\":\"Enables the resolve_directory command without any pre-configured scope.\",\"commands\":{\"allow\":[\"resolve_directory\"],\"deny\":[]}},\"deny-basename\":{\"identifier\":\"deny-basename\",\"description\":\"Denies the basename command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"basename\"]}},\"deny-dirname\":{\"identifier\":\"deny-dirname\",\"description\":\"Denies the dirname command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"dirname\"]}},\"deny-extname\":{\"identifier\":\"deny-extname\",\"description\":\"Denies the extname command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"extname\"]}},\"deny-is-absolute\":{\"identifier\":\"deny-is-absolute\",\"description\":\"Denies the is_absolute command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_absolute\"]}},\"deny-join\":{\"identifier\":\"deny-join\",\"description\":\"Denies the join command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"join\"]}},\"deny-normalize\":{\"identifier\":\"deny-normalize\",\"description\":\"Denies the normalize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"normalize\"]}},\"deny-resolve\":{\"identifier\":\"deny-resolve\",\"description\":\"Denies the resolve command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"resolve\"]}},\"deny-resolve-directory\":{\"identifier\":\"deny-resolve-directory\",\"description\":\"Denies the resolve_directory command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"resolve_directory\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:resources\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-close\"]},\"permissions\":{\"allow-close\":{\"identifier\":\"allow-close\",\"description\":\"Enables the close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"close\"],\"deny\":[]}},\"deny-close\":{\"identifier\":\"deny-close\",\"description\":\"Denies the close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"close\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:tray\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin, which enables all commands.\",\"permissions\":[\"allow-new\",\"allow-get-by-id\",\"allow-remove-by-id\",\"allow-set-icon\",\"allow-set-menu\",\"allow-set-tooltip\",\"allow-set-title\",\"allow-set-visible\",\"allow-set-temp-dir-path\",\"allow-set-icon-as-template\",\"allow-set-show-menu-on-left-click\"]},\"permissions\":{\"allow-get-by-id\":{\"identifier\":\"allow-get-by-id\",\"description\":\"Enables the get_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_by_id\"],\"deny\":[]}},\"allow-new\":{\"identifier\":\"allow-new\",\"description\":\"Enables the new command without any pre-configured scope.\",\"commands\":{\"allow\":[\"new\"],\"deny\":[]}},\"allow-remove-by-id\":{\"identifier\":\"allow-remove-by-id\",\"description\":\"Enables the remove_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[\"remove_by_id\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-icon-as-template\":{\"identifier\":\"allow-set-icon-as-template\",\"description\":\"Enables the set_icon_as_template command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon_as_template\"],\"deny\":[]}},\"allow-set-menu\":{\"identifier\":\"allow-set-menu\",\"description\":\"Enables the set_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_menu\"],\"deny\":[]}},\"allow-set-show-menu-on-left-click\":{\"identifier\":\"allow-set-show-menu-on-left-click\",\"description\":\"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_show_menu_on_left_click\"],\"deny\":[]}},\"allow-set-temp-dir-path\":{\"identifier\":\"allow-set-temp-dir-path\",\"description\":\"Enables the set_temp_dir_path command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_temp_dir_path\"],\"deny\":[]}},\"allow-set-title\":{\"identifier\":\"allow-set-title\",\"description\":\"Enables the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title\"],\"deny\":[]}},\"allow-set-tooltip\":{\"identifier\":\"allow-set-tooltip\",\"description\":\"Enables the set_tooltip command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_tooltip\"],\"deny\":[]}},\"allow-set-visible\":{\"identifier\":\"allow-set-visible\",\"description\":\"Enables the set_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_visible\"],\"deny\":[]}},\"deny-get-by-id\":{\"identifier\":\"deny-get-by-id\",\"description\":\"Denies the get_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_by_id\"]}},\"deny-new\":{\"identifier\":\"deny-new\",\"description\":\"Denies the new command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"new\"]}},\"deny-remove-by-id\":{\"identifier\":\"deny-remove-by-id\",\"description\":\"Denies the remove_by_id command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"remove_by_id\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-icon-as-template\":{\"identifier\":\"deny-set-icon-as-template\",\"description\":\"Denies the set_icon_as_template command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon_as_template\"]}},\"deny-set-menu\":{\"identifier\":\"deny-set-menu\",\"description\":\"Denies the set_menu command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_menu\"]}},\"deny-set-show-menu-on-left-click\":{\"identifier\":\"deny-set-show-menu-on-left-click\",\"description\":\"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_show_menu_on_left_click\"]}},\"deny-set-temp-dir-path\":{\"identifier\":\"deny-set-temp-dir-path\",\"description\":\"Denies the set_temp_dir_path command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_temp_dir_path\"]}},\"deny-set-title\":{\"identifier\":\"deny-set-title\",\"description\":\"Denies the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title\"]}},\"deny-set-tooltip\":{\"identifier\":\"deny-set-tooltip\",\"description\":\"Denies the set_tooltip command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_tooltip\"]}},\"deny-set-visible\":{\"identifier\":\"deny-set-visible\",\"description\":\"Denies the set_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_visible\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:webview\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-get-all-webviews\",\"allow-webview-position\",\"allow-webview-size\",\"allow-internal-toggle-devtools\"]},\"permissions\":{\"allow-clear-all-browsing-data\":{\"identifier\":\"allow-clear-all-browsing-data\",\"description\":\"Enables the clear_all_browsing_data command without any pre-configured scope.\",\"commands\":{\"allow\":[\"clear_all_browsing_data\"],\"deny\":[]}},\"allow-create-webview\":{\"identifier\":\"allow-create-webview\",\"description\":\"Enables the create_webview command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_webview\"],\"deny\":[]}},\"allow-create-webview-window\":{\"identifier\":\"allow-create-webview-window\",\"description\":\"Enables the create_webview_window command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create_webview_window\"],\"deny\":[]}},\"allow-get-all-webviews\":{\"identifier\":\"allow-get-all-webviews\",\"description\":\"Enables the get_all_webviews command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_all_webviews\"],\"deny\":[]}},\"allow-internal-toggle-devtools\":{\"identifier\":\"allow-internal-toggle-devtools\",\"description\":\"Enables the internal_toggle_devtools command without any pre-configured scope.\",\"commands\":{\"allow\":[\"internal_toggle_devtools\"],\"deny\":[]}},\"allow-print\":{\"identifier\":\"allow-print\",\"description\":\"Enables the print command without any pre-configured scope.\",\"commands\":{\"allow\":[\"print\"],\"deny\":[]}},\"allow-reparent\":{\"identifier\":\"allow-reparent\",\"description\":\"Enables the reparent command without any pre-configured scope.\",\"commands\":{\"allow\":[\"reparent\"],\"deny\":[]}},\"allow-set-webview-auto-resize\":{\"identifier\":\"allow-set-webview-auto-resize\",\"description\":\"Enables the set_webview_auto_resize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_auto_resize\"],\"deny\":[]}},\"allow-set-webview-background-color\":{\"identifier\":\"allow-set-webview-background-color\",\"description\":\"Enables the set_webview_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_background_color\"],\"deny\":[]}},\"allow-set-webview-focus\":{\"identifier\":\"allow-set-webview-focus\",\"description\":\"Enables the set_webview_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_focus\"],\"deny\":[]}},\"allow-set-webview-position\":{\"identifier\":\"allow-set-webview-position\",\"description\":\"Enables the set_webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_position\"],\"deny\":[]}},\"allow-set-webview-size\":{\"identifier\":\"allow-set-webview-size\",\"description\":\"Enables the set_webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_size\"],\"deny\":[]}},\"allow-set-webview-zoom\":{\"identifier\":\"allow-set-webview-zoom\",\"description\":\"Enables the set_webview_zoom command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_webview_zoom\"],\"deny\":[]}},\"allow-webview-close\":{\"identifier\":\"allow-webview-close\",\"description\":\"Enables the webview_close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_close\"],\"deny\":[]}},\"allow-webview-hide\":{\"identifier\":\"allow-webview-hide\",\"description\":\"Enables the webview_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_hide\"],\"deny\":[]}},\"allow-webview-position\":{\"identifier\":\"allow-webview-position\",\"description\":\"Enables the webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_position\"],\"deny\":[]}},\"allow-webview-show\":{\"identifier\":\"allow-webview-show\",\"description\":\"Enables the webview_show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_show\"],\"deny\":[]}},\"allow-webview-size\":{\"identifier\":\"allow-webview-size\",\"description\":\"Enables the webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"webview_size\"],\"deny\":[]}},\"deny-clear-all-browsing-data\":{\"identifier\":\"deny-clear-all-browsing-data\",\"description\":\"Denies the clear_all_browsing_data command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"clear_all_browsing_data\"]}},\"deny-create-webview\":{\"identifier\":\"deny-create-webview\",\"description\":\"Denies the create_webview command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_webview\"]}},\"deny-create-webview-window\":{\"identifier\":\"deny-create-webview-window\",\"description\":\"Denies the create_webview_window command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create_webview_window\"]}},\"deny-get-all-webviews\":{\"identifier\":\"deny-get-all-webviews\",\"description\":\"Denies the get_all_webviews command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_all_webviews\"]}},\"deny-internal-toggle-devtools\":{\"identifier\":\"deny-internal-toggle-devtools\",\"description\":\"Denies the internal_toggle_devtools command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"internal_toggle_devtools\"]}},\"deny-print\":{\"identifier\":\"deny-print\",\"description\":\"Denies the print command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"print\"]}},\"deny-reparent\":{\"identifier\":\"deny-reparent\",\"description\":\"Denies the reparent command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"reparent\"]}},\"deny-set-webview-auto-resize\":{\"identifier\":\"deny-set-webview-auto-resize\",\"description\":\"Denies the set_webview_auto_resize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_auto_resize\"]}},\"deny-set-webview-background-color\":{\"identifier\":\"deny-set-webview-background-color\",\"description\":\"Denies the set_webview_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_background_color\"]}},\"deny-set-webview-focus\":{\"identifier\":\"deny-set-webview-focus\",\"description\":\"Denies the set_webview_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_focus\"]}},\"deny-set-webview-position\":{\"identifier\":\"deny-set-webview-position\",\"description\":\"Denies the set_webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_position\"]}},\"deny-set-webview-size\":{\"identifier\":\"deny-set-webview-size\",\"description\":\"Denies the set_webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_size\"]}},\"deny-set-webview-zoom\":{\"identifier\":\"deny-set-webview-zoom\",\"description\":\"Denies the set_webview_zoom command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_webview_zoom\"]}},\"deny-webview-close\":{\"identifier\":\"deny-webview-close\",\"description\":\"Denies the webview_close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_close\"]}},\"deny-webview-hide\":{\"identifier\":\"deny-webview-hide\",\"description\":\"Denies the webview_hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_hide\"]}},\"deny-webview-position\":{\"identifier\":\"deny-webview-position\",\"description\":\"Denies the webview_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_position\"]}},\"deny-webview-show\":{\"identifier\":\"deny-webview-show\",\"description\":\"Denies the webview_show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_show\"]}},\"deny-webview-size\":{\"identifier\":\"deny-webview-size\",\"description\":\"Denies the webview_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"webview_size\"]}}},\"permission_sets\":{},\"global_scope_schema\":null},\"core:window\":{\"default_permission\":{\"identifier\":\"default\",\"description\":\"Default permissions for the plugin.\",\"permissions\":[\"allow-get-all-windows\",\"allow-scale-factor\",\"allow-inner-position\",\"allow-outer-position\",\"allow-inner-size\",\"allow-outer-size\",\"allow-is-fullscreen\",\"allow-is-minimized\",\"allow-is-maximized\",\"allow-is-focused\",\"allow-is-decorated\",\"allow-is-resizable\",\"allow-is-maximizable\",\"allow-is-minimizable\",\"allow-is-closable\",\"allow-is-visible\",\"allow-is-enabled\",\"allow-title\",\"allow-current-monitor\",\"allow-primary-monitor\",\"allow-monitor-from-point\",\"allow-available-monitors\",\"allow-cursor-position\",\"allow-theme\",\"allow-is-always-on-top\",\"allow-internal-toggle-maximize\"]},\"permissions\":{\"allow-available-monitors\":{\"identifier\":\"allow-available-monitors\",\"description\":\"Enables the available_monitors command without any pre-configured scope.\",\"commands\":{\"allow\":[\"available_monitors\"],\"deny\":[]}},\"allow-center\":{\"identifier\":\"allow-center\",\"description\":\"Enables the center command without any pre-configured scope.\",\"commands\":{\"allow\":[\"center\"],\"deny\":[]}},\"allow-close\":{\"identifier\":\"allow-close\",\"description\":\"Enables the close command without any pre-configured scope.\",\"commands\":{\"allow\":[\"close\"],\"deny\":[]}},\"allow-create\":{\"identifier\":\"allow-create\",\"description\":\"Enables the create command without any pre-configured scope.\",\"commands\":{\"allow\":[\"create\"],\"deny\":[]}},\"allow-current-monitor\":{\"identifier\":\"allow-current-monitor\",\"description\":\"Enables the current_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"current_monitor\"],\"deny\":[]}},\"allow-cursor-position\":{\"identifier\":\"allow-cursor-position\",\"description\":\"Enables the cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"cursor_position\"],\"deny\":[]}},\"allow-destroy\":{\"identifier\":\"allow-destroy\",\"description\":\"Enables the destroy command without any pre-configured scope.\",\"commands\":{\"allow\":[\"destroy\"],\"deny\":[]}},\"allow-get-all-windows\":{\"identifier\":\"allow-get-all-windows\",\"description\":\"Enables the get_all_windows command without any pre-configured scope.\",\"commands\":{\"allow\":[\"get_all_windows\"],\"deny\":[]}},\"allow-hide\":{\"identifier\":\"allow-hide\",\"description\":\"Enables the hide command without any pre-configured scope.\",\"commands\":{\"allow\":[\"hide\"],\"deny\":[]}},\"allow-inner-position\":{\"identifier\":\"allow-inner-position\",\"description\":\"Enables the inner_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"inner_position\"],\"deny\":[]}},\"allow-inner-size\":{\"identifier\":\"allow-inner-size\",\"description\":\"Enables the inner_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"inner_size\"],\"deny\":[]}},\"allow-internal-toggle-maximize\":{\"identifier\":\"allow-internal-toggle-maximize\",\"description\":\"Enables the internal_toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"internal_toggle_maximize\"],\"deny\":[]}},\"allow-is-always-on-top\":{\"identifier\":\"allow-is-always-on-top\",\"description\":\"Enables the is_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_always_on_top\"],\"deny\":[]}},\"allow-is-closable\":{\"identifier\":\"allow-is-closable\",\"description\":\"Enables the is_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_closable\"],\"deny\":[]}},\"allow-is-decorated\":{\"identifier\":\"allow-is-decorated\",\"description\":\"Enables the is_decorated command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_decorated\"],\"deny\":[]}},\"allow-is-enabled\":{\"identifier\":\"allow-is-enabled\",\"description\":\"Enables the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_enabled\"],\"deny\":[]}},\"allow-is-focused\":{\"identifier\":\"allow-is-focused\",\"description\":\"Enables the is_focused command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_focused\"],\"deny\":[]}},\"allow-is-fullscreen\":{\"identifier\":\"allow-is-fullscreen\",\"description\":\"Enables the is_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_fullscreen\"],\"deny\":[]}},\"allow-is-maximizable\":{\"identifier\":\"allow-is-maximizable\",\"description\":\"Enables the is_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_maximizable\"],\"deny\":[]}},\"allow-is-maximized\":{\"identifier\":\"allow-is-maximized\",\"description\":\"Enables the is_maximized command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_maximized\"],\"deny\":[]}},\"allow-is-minimizable\":{\"identifier\":\"allow-is-minimizable\",\"description\":\"Enables the is_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_minimizable\"],\"deny\":[]}},\"allow-is-minimized\":{\"identifier\":\"allow-is-minimized\",\"description\":\"Enables the is_minimized command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_minimized\"],\"deny\":[]}},\"allow-is-resizable\":{\"identifier\":\"allow-is-resizable\",\"description\":\"Enables the is_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_resizable\"],\"deny\":[]}},\"allow-is-visible\":{\"identifier\":\"allow-is-visible\",\"description\":\"Enables the is_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"is_visible\"],\"deny\":[]}},\"allow-maximize\":{\"identifier\":\"allow-maximize\",\"description\":\"Enables the maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"maximize\"],\"deny\":[]}},\"allow-minimize\":{\"identifier\":\"allow-minimize\",\"description\":\"Enables the minimize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"minimize\"],\"deny\":[]}},\"allow-monitor-from-point\":{\"identifier\":\"allow-monitor-from-point\",\"description\":\"Enables the monitor_from_point command without any pre-configured scope.\",\"commands\":{\"allow\":[\"monitor_from_point\"],\"deny\":[]}},\"allow-outer-position\":{\"identifier\":\"allow-outer-position\",\"description\":\"Enables the outer_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"outer_position\"],\"deny\":[]}},\"allow-outer-size\":{\"identifier\":\"allow-outer-size\",\"description\":\"Enables the outer_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"outer_size\"],\"deny\":[]}},\"allow-primary-monitor\":{\"identifier\":\"allow-primary-monitor\",\"description\":\"Enables the primary_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"primary_monitor\"],\"deny\":[]}},\"allow-request-user-attention\":{\"identifier\":\"allow-request-user-attention\",\"description\":\"Enables the request_user_attention command without any pre-configured scope.\",\"commands\":{\"allow\":[\"request_user_attention\"],\"deny\":[]}},\"allow-scale-factor\":{\"identifier\":\"allow-scale-factor\",\"description\":\"Enables the scale_factor command without any pre-configured scope.\",\"commands\":{\"allow\":[\"scale_factor\"],\"deny\":[]}},\"allow-set-always-on-bottom\":{\"identifier\":\"allow-set-always-on-bottom\",\"description\":\"Enables the set_always_on_bottom command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_always_on_bottom\"],\"deny\":[]}},\"allow-set-always-on-top\":{\"identifier\":\"allow-set-always-on-top\",\"description\":\"Enables the set_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_always_on_top\"],\"deny\":[]}},\"allow-set-background-color\":{\"identifier\":\"allow-set-background-color\",\"description\":\"Enables the set_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_background_color\"],\"deny\":[]}},\"allow-set-badge-count\":{\"identifier\":\"allow-set-badge-count\",\"description\":\"Enables the set_badge_count command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_badge_count\"],\"deny\":[]}},\"allow-set-badge-label\":{\"identifier\":\"allow-set-badge-label\",\"description\":\"Enables the set_badge_label command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_badge_label\"],\"deny\":[]}},\"allow-set-closable\":{\"identifier\":\"allow-set-closable\",\"description\":\"Enables the set_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_closable\"],\"deny\":[]}},\"allow-set-content-protected\":{\"identifier\":\"allow-set-content-protected\",\"description\":\"Enables the set_content_protected command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_content_protected\"],\"deny\":[]}},\"allow-set-cursor-grab\":{\"identifier\":\"allow-set-cursor-grab\",\"description\":\"Enables the set_cursor_grab command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_grab\"],\"deny\":[]}},\"allow-set-cursor-icon\":{\"identifier\":\"allow-set-cursor-icon\",\"description\":\"Enables the set_cursor_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_icon\"],\"deny\":[]}},\"allow-set-cursor-position\":{\"identifier\":\"allow-set-cursor-position\",\"description\":\"Enables the set_cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_position\"],\"deny\":[]}},\"allow-set-cursor-visible\":{\"identifier\":\"allow-set-cursor-visible\",\"description\":\"Enables the set_cursor_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_cursor_visible\"],\"deny\":[]}},\"allow-set-decorations\":{\"identifier\":\"allow-set-decorations\",\"description\":\"Enables the set_decorations command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_decorations\"],\"deny\":[]}},\"allow-set-effects\":{\"identifier\":\"allow-set-effects\",\"description\":\"Enables the set_effects command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_effects\"],\"deny\":[]}},\"allow-set-enabled\":{\"identifier\":\"allow-set-enabled\",\"description\":\"Enables the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_enabled\"],\"deny\":[]}},\"allow-set-focus\":{\"identifier\":\"allow-set-focus\",\"description\":\"Enables the set_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_focus\"],\"deny\":[]}},\"allow-set-focusable\":{\"identifier\":\"allow-set-focusable\",\"description\":\"Enables the set_focusable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_focusable\"],\"deny\":[]}},\"allow-set-fullscreen\":{\"identifier\":\"allow-set-fullscreen\",\"description\":\"Enables the set_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_fullscreen\"],\"deny\":[]}},\"allow-set-icon\":{\"identifier\":\"allow-set-icon\",\"description\":\"Enables the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_icon\"],\"deny\":[]}},\"allow-set-ignore-cursor-events\":{\"identifier\":\"allow-set-ignore-cursor-events\",\"description\":\"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_ignore_cursor_events\"],\"deny\":[]}},\"allow-set-max-size\":{\"identifier\":\"allow-set-max-size\",\"description\":\"Enables the set_max_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_max_size\"],\"deny\":[]}},\"allow-set-maximizable\":{\"identifier\":\"allow-set-maximizable\",\"description\":\"Enables the set_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_maximizable\"],\"deny\":[]}},\"allow-set-min-size\":{\"identifier\":\"allow-set-min-size\",\"description\":\"Enables the set_min_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_min_size\"],\"deny\":[]}},\"allow-set-minimizable\":{\"identifier\":\"allow-set-minimizable\",\"description\":\"Enables the set_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_minimizable\"],\"deny\":[]}},\"allow-set-overlay-icon\":{\"identifier\":\"allow-set-overlay-icon\",\"description\":\"Enables the set_overlay_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_overlay_icon\"],\"deny\":[]}},\"allow-set-position\":{\"identifier\":\"allow-set-position\",\"description\":\"Enables the set_position command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_position\"],\"deny\":[]}},\"allow-set-progress-bar\":{\"identifier\":\"allow-set-progress-bar\",\"description\":\"Enables the set_progress_bar command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_progress_bar\"],\"deny\":[]}},\"allow-set-resizable\":{\"identifier\":\"allow-set-resizable\",\"description\":\"Enables the set_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_resizable\"],\"deny\":[]}},\"allow-set-shadow\":{\"identifier\":\"allow-set-shadow\",\"description\":\"Enables the set_shadow command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_shadow\"],\"deny\":[]}},\"allow-set-simple-fullscreen\":{\"identifier\":\"allow-set-simple-fullscreen\",\"description\":\"Enables the set_simple_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_simple_fullscreen\"],\"deny\":[]}},\"allow-set-size\":{\"identifier\":\"allow-set-size\",\"description\":\"Enables the set_size command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_size\"],\"deny\":[]}},\"allow-set-size-constraints\":{\"identifier\":\"allow-set-size-constraints\",\"description\":\"Enables the set_size_constraints command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_size_constraints\"],\"deny\":[]}},\"allow-set-skip-taskbar\":{\"identifier\":\"allow-set-skip-taskbar\",\"description\":\"Enables the set_skip_taskbar command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_skip_taskbar\"],\"deny\":[]}},\"allow-set-theme\":{\"identifier\":\"allow-set-theme\",\"description\":\"Enables the set_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_theme\"],\"deny\":[]}},\"allow-set-title\":{\"identifier\":\"allow-set-title\",\"description\":\"Enables the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title\"],\"deny\":[]}},\"allow-set-title-bar-style\":{\"identifier\":\"allow-set-title-bar-style\",\"description\":\"Enables the set_title_bar_style command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_title_bar_style\"],\"deny\":[]}},\"allow-set-visible-on-all-workspaces\":{\"identifier\":\"allow-set-visible-on-all-workspaces\",\"description\":\"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\"commands\":{\"allow\":[\"set_visible_on_all_workspaces\"],\"deny\":[]}},\"allow-show\":{\"identifier\":\"allow-show\",\"description\":\"Enables the show command without any pre-configured scope.\",\"commands\":{\"allow\":[\"show\"],\"deny\":[]}},\"allow-start-dragging\":{\"identifier\":\"allow-start-dragging\",\"description\":\"Enables the start_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[\"start_dragging\"],\"deny\":[]}},\"allow-start-resize-dragging\":{\"identifier\":\"allow-start-resize-dragging\",\"description\":\"Enables the start_resize_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[\"start_resize_dragging\"],\"deny\":[]}},\"allow-theme\":{\"identifier\":\"allow-theme\",\"description\":\"Enables the theme command without any pre-configured scope.\",\"commands\":{\"allow\":[\"theme\"],\"deny\":[]}},\"allow-title\":{\"identifier\":\"allow-title\",\"description\":\"Enables the title command without any pre-configured scope.\",\"commands\":{\"allow\":[\"title\"],\"deny\":[]}},\"allow-toggle-maximize\":{\"identifier\":\"allow-toggle-maximize\",\"description\":\"Enables the toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"toggle_maximize\"],\"deny\":[]}},\"allow-unmaximize\":{\"identifier\":\"allow-unmaximize\",\"description\":\"Enables the unmaximize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unmaximize\"],\"deny\":[]}},\"allow-unminimize\":{\"identifier\":\"allow-unminimize\",\"description\":\"Enables the unminimize command without any pre-configured scope.\",\"commands\":{\"allow\":[\"unminimize\"],\"deny\":[]}},\"deny-available-monitors\":{\"identifier\":\"deny-available-monitors\",\"description\":\"Denies the available_monitors command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"available_monitors\"]}},\"deny-center\":{\"identifier\":\"deny-center\",\"description\":\"Denies the center command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"center\"]}},\"deny-close\":{\"identifier\":\"deny-close\",\"description\":\"Denies the close command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"close\"]}},\"deny-create\":{\"identifier\":\"deny-create\",\"description\":\"Denies the create command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"create\"]}},\"deny-current-monitor\":{\"identifier\":\"deny-current-monitor\",\"description\":\"Denies the current_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"current_monitor\"]}},\"deny-cursor-position\":{\"identifier\":\"deny-cursor-position\",\"description\":\"Denies the cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"cursor_position\"]}},\"deny-destroy\":{\"identifier\":\"deny-destroy\",\"description\":\"Denies the destroy command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"destroy\"]}},\"deny-get-all-windows\":{\"identifier\":\"deny-get-all-windows\",\"description\":\"Denies the get_all_windows command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"get_all_windows\"]}},\"deny-hide\":{\"identifier\":\"deny-hide\",\"description\":\"Denies the hide command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"hide\"]}},\"deny-inner-position\":{\"identifier\":\"deny-inner-position\",\"description\":\"Denies the inner_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"inner_position\"]}},\"deny-inner-size\":{\"identifier\":\"deny-inner-size\",\"description\":\"Denies the inner_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"inner_size\"]}},\"deny-internal-toggle-maximize\":{\"identifier\":\"deny-internal-toggle-maximize\",\"description\":\"Denies the internal_toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"internal_toggle_maximize\"]}},\"deny-is-always-on-top\":{\"identifier\":\"deny-is-always-on-top\",\"description\":\"Denies the is_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_always_on_top\"]}},\"deny-is-closable\":{\"identifier\":\"deny-is-closable\",\"description\":\"Denies the is_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_closable\"]}},\"deny-is-decorated\":{\"identifier\":\"deny-is-decorated\",\"description\":\"Denies the is_decorated command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_decorated\"]}},\"deny-is-enabled\":{\"identifier\":\"deny-is-enabled\",\"description\":\"Denies the is_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_enabled\"]}},\"deny-is-focused\":{\"identifier\":\"deny-is-focused\",\"description\":\"Denies the is_focused command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_focused\"]}},\"deny-is-fullscreen\":{\"identifier\":\"deny-is-fullscreen\",\"description\":\"Denies the is_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_fullscreen\"]}},\"deny-is-maximizable\":{\"identifier\":\"deny-is-maximizable\",\"description\":\"Denies the is_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_maximizable\"]}},\"deny-is-maximized\":{\"identifier\":\"deny-is-maximized\",\"description\":\"Denies the is_maximized command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_maximized\"]}},\"deny-is-minimizable\":{\"identifier\":\"deny-is-minimizable\",\"description\":\"Denies the is_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_minimizable\"]}},\"deny-is-minimized\":{\"identifier\":\"deny-is-minimized\",\"description\":\"Denies the is_minimized command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_minimized\"]}},\"deny-is-resizable\":{\"identifier\":\"deny-is-resizable\",\"description\":\"Denies the is_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_resizable\"]}},\"deny-is-visible\":{\"identifier\":\"deny-is-visible\",\"description\":\"Denies the is_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"is_visible\"]}},\"deny-maximize\":{\"identifier\":\"deny-maximize\",\"description\":\"Denies the maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"maximize\"]}},\"deny-minimize\":{\"identifier\":\"deny-minimize\",\"description\":\"Denies the minimize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"minimize\"]}},\"deny-monitor-from-point\":{\"identifier\":\"deny-monitor-from-point\",\"description\":\"Denies the monitor_from_point command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"monitor_from_point\"]}},\"deny-outer-position\":{\"identifier\":\"deny-outer-position\",\"description\":\"Denies the outer_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"outer_position\"]}},\"deny-outer-size\":{\"identifier\":\"deny-outer-size\",\"description\":\"Denies the outer_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"outer_size\"]}},\"deny-primary-monitor\":{\"identifier\":\"deny-primary-monitor\",\"description\":\"Denies the primary_monitor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"primary_monitor\"]}},\"deny-request-user-attention\":{\"identifier\":\"deny-request-user-attention\",\"description\":\"Denies the request_user_attention command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"request_user_attention\"]}},\"deny-scale-factor\":{\"identifier\":\"deny-scale-factor\",\"description\":\"Denies the scale_factor command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"scale_factor\"]}},\"deny-set-always-on-bottom\":{\"identifier\":\"deny-set-always-on-bottom\",\"description\":\"Denies the set_always_on_bottom command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_always_on_bottom\"]}},\"deny-set-always-on-top\":{\"identifier\":\"deny-set-always-on-top\",\"description\":\"Denies the set_always_on_top command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_always_on_top\"]}},\"deny-set-background-color\":{\"identifier\":\"deny-set-background-color\",\"description\":\"Denies the set_background_color command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_background_color\"]}},\"deny-set-badge-count\":{\"identifier\":\"deny-set-badge-count\",\"description\":\"Denies the set_badge_count command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_badge_count\"]}},\"deny-set-badge-label\":{\"identifier\":\"deny-set-badge-label\",\"description\":\"Denies the set_badge_label command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_badge_label\"]}},\"deny-set-closable\":{\"identifier\":\"deny-set-closable\",\"description\":\"Denies the set_closable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_closable\"]}},\"deny-set-content-protected\":{\"identifier\":\"deny-set-content-protected\",\"description\":\"Denies the set_content_protected command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_content_protected\"]}},\"deny-set-cursor-grab\":{\"identifier\":\"deny-set-cursor-grab\",\"description\":\"Denies the set_cursor_grab command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_grab\"]}},\"deny-set-cursor-icon\":{\"identifier\":\"deny-set-cursor-icon\",\"description\":\"Denies the set_cursor_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_icon\"]}},\"deny-set-cursor-position\":{\"identifier\":\"deny-set-cursor-position\",\"description\":\"Denies the set_cursor_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_position\"]}},\"deny-set-cursor-visible\":{\"identifier\":\"deny-set-cursor-visible\",\"description\":\"Denies the set_cursor_visible command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_cursor_visible\"]}},\"deny-set-decorations\":{\"identifier\":\"deny-set-decorations\",\"description\":\"Denies the set_decorations command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_decorations\"]}},\"deny-set-effects\":{\"identifier\":\"deny-set-effects\",\"description\":\"Denies the set_effects command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_effects\"]}},\"deny-set-enabled\":{\"identifier\":\"deny-set-enabled\",\"description\":\"Denies the set_enabled command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_enabled\"]}},\"deny-set-focus\":{\"identifier\":\"deny-set-focus\",\"description\":\"Denies the set_focus command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_focus\"]}},\"deny-set-focusable\":{\"identifier\":\"deny-set-focusable\",\"description\":\"Denies the set_focusable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_focusable\"]}},\"deny-set-fullscreen\":{\"identifier\":\"deny-set-fullscreen\",\"description\":\"Denies the set_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_fullscreen\"]}},\"deny-set-icon\":{\"identifier\":\"deny-set-icon\",\"description\":\"Denies the set_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_icon\"]}},\"deny-set-ignore-cursor-events\":{\"identifier\":\"deny-set-ignore-cursor-events\",\"description\":\"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_ignore_cursor_events\"]}},\"deny-set-max-size\":{\"identifier\":\"deny-set-max-size\",\"description\":\"Denies the set_max_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_max_size\"]}},\"deny-set-maximizable\":{\"identifier\":\"deny-set-maximizable\",\"description\":\"Denies the set_maximizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_maximizable\"]}},\"deny-set-min-size\":{\"identifier\":\"deny-set-min-size\",\"description\":\"Denies the set_min_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_min_size\"]}},\"deny-set-minimizable\":{\"identifier\":\"deny-set-minimizable\",\"description\":\"Denies the set_minimizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_minimizable\"]}},\"deny-set-overlay-icon\":{\"identifier\":\"deny-set-overlay-icon\",\"description\":\"Denies the set_overlay_icon command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_overlay_icon\"]}},\"deny-set-position\":{\"identifier\":\"deny-set-position\",\"description\":\"Denies the set_position command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_position\"]}},\"deny-set-progress-bar\":{\"identifier\":\"deny-set-progress-bar\",\"description\":\"Denies the set_progress_bar command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_progress_bar\"]}},\"deny-set-resizable\":{\"identifier\":\"deny-set-resizable\",\"description\":\"Denies the set_resizable command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_resizable\"]}},\"deny-set-shadow\":{\"identifier\":\"deny-set-shadow\",\"description\":\"Denies the set_shadow command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_shadow\"]}},\"deny-set-simple-fullscreen\":{\"identifier\":\"deny-set-simple-fullscreen\",\"description\":\"Denies the set_simple_fullscreen command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_simple_fullscreen\"]}},\"deny-set-size\":{\"identifier\":\"deny-set-size\",\"description\":\"Denies the set_size command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_size\"]}},\"deny-set-size-constraints\":{\"identifier\":\"deny-set-size-constraints\",\"description\":\"Denies the set_size_constraints command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_size_constraints\"]}},\"deny-set-skip-taskbar\":{\"identifier\":\"deny-set-skip-taskbar\",\"description\":\"Denies the set_skip_taskbar command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_skip_taskbar\"]}},\"deny-set-theme\":{\"identifier\":\"deny-set-theme\",\"description\":\"Denies the set_theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_theme\"]}},\"deny-set-title\":{\"identifier\":\"deny-set-title\",\"description\":\"Denies the set_title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title\"]}},\"deny-set-title-bar-style\":{\"identifier\":\"deny-set-title-bar-style\",\"description\":\"Denies the set_title_bar_style command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_title_bar_style\"]}},\"deny-set-visible-on-all-workspaces\":{\"identifier\":\"deny-set-visible-on-all-workspaces\",\"description\":\"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"set_visible_on_all_workspaces\"]}},\"deny-show\":{\"identifier\":\"deny-show\",\"description\":\"Denies the show command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"show\"]}},\"deny-start-dragging\":{\"identifier\":\"deny-start-dragging\",\"description\":\"Denies the start_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"start_dragging\"]}},\"deny-start-resize-dragging\":{\"identifier\":\"deny-start-resize-dragging\",\"description\":\"Denies the start_resize_dragging command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"start_resize_dragging\"]}},\"deny-theme\":{\"identifier\":\"deny-theme\",\"description\":\"Denies the theme command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"theme\"]}},\"deny-title\":{\"identifier\":\"deny-title\",\"description\":\"Denies the title command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"title\"]}},\"deny-toggle-maximize\":{\"identifier\":\"deny-toggle-maximize\",\"description\":\"Denies the toggle_maximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"toggle_maximize\"]}},\"deny-unmaximize\":{\"identifier\":\"deny-unmaximize\",\"description\":\"Denies the unmaximize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unmaximize\"]}},\"deny-unminimize\":{\"identifier\":\"deny-unminimize\",\"description\":\"Denies the unminimize command without any pre-configured scope.\",\"commands\":{\"allow\":[],\"deny\":[\"unminimize\"]}}},\"permission_sets\":{},\"global_scope_schema\":null}}"
  },
  {
    "path": "desktop-pet/src-tauri/gen/schemas/capabilities.json",
    "content": "{\"default\":{\"identifier\":\"default\",\"description\":\"Default capabilities for the desktop pet\",\"remote\":{\"urls\":[\"http://127.0.0.1:*\",\"http://localhost:*\"]},\"local\":true,\"windows\":[\"main\",\"mini\"],\"permissions\":[\"core:default\",\"core:window:allow-start-dragging\",\"core:window:allow-close\",\"core:window:allow-set-size\"]}}"
  },
  {
    "path": "desktop-pet/src-tauri/gen/schemas/desktop-schema.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"CapabilityFile\",\n  \"description\": \"Capability formats accepted in a capability file.\",\n  \"anyOf\": [\n    {\n      \"description\": \"A single capability.\",\n      \"allOf\": [\n        {\n          \"$ref\": \"#/definitions/Capability\"\n        }\n      ]\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Capability\"\n      }\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"capabilities\"\n      ],\n      \"properties\": {\n        \"capabilities\": {\n          \"description\": \"The list of capabilities.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Capability\"\n          }\n        }\n      }\n    }\n  ],\n  \"definitions\": {\n    \"Capability\": {\n      \"description\": \"A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\\n\\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\\n\\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\\n\\n## Example\\n\\n```json { \\\"identifier\\\": \\\"main-user-files-write\\\", \\\"description\\\": \\\"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\\\", \\\"windows\\\": [ \\\"main\\\" ], \\\"permissions\\\": [ \\\"core:default\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] }, ], \\\"platforms\\\": [\\\"macOS\\\",\\\"windows\\\"] } ```\",\n      \"type\": \"object\",\n      \"required\": [\n        \"identifier\",\n        \"permissions\"\n      ],\n      \"properties\": {\n        \"identifier\": {\n          \"description\": \"Identifier of the capability.\\n\\n## Example\\n\\n`main-user-files-write`\",\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"description\": \"Description of what the capability is intended to allow on associated windows.\\n\\nIt should contain a description of what the grouped permissions should allow.\\n\\n## Example\\n\\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\",\n          \"default\": \"\",\n          \"type\": \"string\"\n        },\n        \"remote\": {\n          \"description\": \"Configure remote URLs that can use the capability permissions.\\n\\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\\n\\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\\n\\n## Example\\n\\n```json { \\\"urls\\\": [\\\"https://*.mydomain.dev\\\"] } ```\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/CapabilityRemote\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"local\": {\n          \"description\": \"Whether this capability is enabled for local app URLs or not. Defaults to `true`.\",\n          \"default\": true,\n          \"type\": \"boolean\"\n        },\n        \"windows\": {\n          \"description\": \"List of windows that are affected by this capability. Can be a glob pattern.\\n\\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\\n\\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\\n\\n## Example\\n\\n`[\\\"main\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"webviews\": {\n          \"description\": \"List of webviews that are affected by this capability. Can be a glob pattern.\\n\\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\\n\\n## Example\\n\\n`[\\\"sub-webview-one\\\", \\\"sub-webview-two\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"permissions\": {\n          \"description\": \"List of permissions attached to this capability.\\n\\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\\n\\n## Example\\n\\n```json [ \\\"core:default\\\", \\\"shell:allow-open\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] } ] ```\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/PermissionEntry\"\n          },\n          \"uniqueItems\": true\n        },\n        \"platforms\": {\n          \"description\": \"Limit which target platforms this capability applies to.\\n\\nBy default all platforms are targeted.\\n\\n## Example\\n\\n`[\\\"macOS\\\",\\\"windows\\\"]`\",\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"$ref\": \"#/definitions/Target\"\n          }\n        }\n      }\n    },\n    \"CapabilityRemote\": {\n      \"description\": \"Configuration for remote URLs that are associated with the capability.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"urls\"\n      ],\n      \"properties\": {\n        \"urls\": {\n          \"description\": \"Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\\n\\n## Examples\\n\\n- \\\"https://*.mydomain.dev\\\": allows subdomains of mydomain.dev - \\\"https://mydomain.dev/api/*\\\": allows any subpath of mydomain.dev/api\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    },\n    \"PermissionEntry\": {\n      \"description\": \"An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Reference a permission or permission set by identifier.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Identifier\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Reference a permission or permission set by identifier and extends its scope.\",\n          \"type\": \"object\",\n          \"allOf\": [\n            {\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                },\n                \"allow\": {\n                  \"description\": \"Data that defines what is allowed by the scope.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                },\n                \"deny\": {\n                  \"description\": \"Data that defines what is denied by the scope. This should be prioritized by validation logic.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                }\n              }\n            }\n          ],\n          \"required\": [\n            \"identifier\"\n          ]\n        }\n      ]\n    },\n    \"Identifier\": {\n      \"description\": \"Permission identifier\",\n      \"oneOf\": [\n        {\n          \"description\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\",\n          \"type\": \"string\",\n          \"const\": \"core:default\",\n          \"markdownDescription\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\",\n          \"type\": \"string\",\n          \"const\": \"core:app:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\"\n        },\n        {\n          \"description\": \"Enables the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-hide\",\n          \"markdownDescription\": \"Enables the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-show\",\n          \"markdownDescription\": \"Enables the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-bundle-type\",\n          \"markdownDescription\": \"Enables the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-default-window-icon\",\n          \"markdownDescription\": \"Enables the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-identifier\",\n          \"markdownDescription\": \"Enables the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-name\",\n          \"markdownDescription\": \"Enables the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-data-store\",\n          \"markdownDescription\": \"Enables the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-listener\",\n          \"markdownDescription\": \"Enables the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-app-theme\",\n          \"markdownDescription\": \"Enables the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-dock-visibility\",\n          \"markdownDescription\": \"Enables the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-tauri-version\",\n          \"markdownDescription\": \"Enables the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-version\",\n          \"markdownDescription\": \"Enables the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-hide\",\n          \"markdownDescription\": \"Denies the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-show\",\n          \"markdownDescription\": \"Denies the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-bundle-type\",\n          \"markdownDescription\": \"Denies the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-default-window-icon\",\n          \"markdownDescription\": \"Denies the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-identifier\",\n          \"markdownDescription\": \"Denies the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-name\",\n          \"markdownDescription\": \"Denies the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-data-store\",\n          \"markdownDescription\": \"Denies the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-listener\",\n          \"markdownDescription\": \"Denies the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-app-theme\",\n          \"markdownDescription\": \"Denies the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-dock-visibility\",\n          \"markdownDescription\": \"Denies the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-tauri-version\",\n          \"markdownDescription\": \"Denies the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-version\",\n          \"markdownDescription\": \"Denies the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\",\n          \"type\": \"string\",\n          \"const\": \"core:event:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\"\n        },\n        {\n          \"description\": \"Enables the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit\",\n          \"markdownDescription\": \"Enables the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit-to\",\n          \"markdownDescription\": \"Enables the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-listen\",\n          \"markdownDescription\": \"Enables the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-unlisten\",\n          \"markdownDescription\": \"Enables the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit\",\n          \"markdownDescription\": \"Denies the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit-to\",\n          \"markdownDescription\": \"Denies the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-listen\",\n          \"markdownDescription\": \"Denies the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-unlisten\",\n          \"markdownDescription\": \"Denies the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\",\n          \"type\": \"string\",\n          \"const\": \"core:image:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\"\n        },\n        {\n          \"description\": \"Enables the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-bytes\",\n          \"markdownDescription\": \"Enables the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-path\",\n          \"markdownDescription\": \"Enables the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-rgba\",\n          \"markdownDescription\": \"Enables the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-size\",\n          \"markdownDescription\": \"Enables the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-bytes\",\n          \"markdownDescription\": \"Denies the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-path\",\n          \"markdownDescription\": \"Denies the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-rgba\",\n          \"markdownDescription\": \"Denies the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-size\",\n          \"markdownDescription\": \"Denies the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\"\n        },\n        {\n          \"description\": \"Enables the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-append\",\n          \"markdownDescription\": \"Enables the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-create-default\",\n          \"markdownDescription\": \"Enables the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-get\",\n          \"markdownDescription\": \"Enables the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-insert\",\n          \"markdownDescription\": \"Enables the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-checked\",\n          \"markdownDescription\": \"Enables the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-items\",\n          \"markdownDescription\": \"Enables the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-popup\",\n          \"markdownDescription\": \"Enables the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-prepend\",\n          \"markdownDescription\": \"Enables the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove\",\n          \"markdownDescription\": \"Enables the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove-at\",\n          \"markdownDescription\": \"Enables the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-accelerator\",\n          \"markdownDescription\": \"Enables the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-app-menu\",\n          \"markdownDescription\": \"Enables the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-window-menu\",\n          \"markdownDescription\": \"Enables the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-checked\",\n          \"markdownDescription\": \"Enables the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-text\",\n          \"markdownDescription\": \"Enables the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-text\",\n          \"markdownDescription\": \"Enables the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-append\",\n          \"markdownDescription\": \"Denies the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-create-default\",\n          \"markdownDescription\": \"Denies the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-get\",\n          \"markdownDescription\": \"Denies the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-insert\",\n          \"markdownDescription\": \"Denies the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-checked\",\n          \"markdownDescription\": \"Denies the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-items\",\n          \"markdownDescription\": \"Denies the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-popup\",\n          \"markdownDescription\": \"Denies the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-prepend\",\n          \"markdownDescription\": \"Denies the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove\",\n          \"markdownDescription\": \"Denies the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove-at\",\n          \"markdownDescription\": \"Denies the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-accelerator\",\n          \"markdownDescription\": \"Denies the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-app-menu\",\n          \"markdownDescription\": \"Denies the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-window-menu\",\n          \"markdownDescription\": \"Denies the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-checked\",\n          \"markdownDescription\": \"Denies the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-text\",\n          \"markdownDescription\": \"Denies the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-text\",\n          \"markdownDescription\": \"Denies the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\",\n          \"type\": \"string\",\n          \"const\": \"core:path:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\"\n        },\n        {\n          \"description\": \"Enables the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-basename\",\n          \"markdownDescription\": \"Enables the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-dirname\",\n          \"markdownDescription\": \"Enables the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-extname\",\n          \"markdownDescription\": \"Enables the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-is-absolute\",\n          \"markdownDescription\": \"Enables the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-join\",\n          \"markdownDescription\": \"Enables the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-normalize\",\n          \"markdownDescription\": \"Enables the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve\",\n          \"markdownDescription\": \"Enables the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve-directory\",\n          \"markdownDescription\": \"Enables the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-basename\",\n          \"markdownDescription\": \"Denies the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-dirname\",\n          \"markdownDescription\": \"Denies the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-extname\",\n          \"markdownDescription\": \"Denies the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-is-absolute\",\n          \"markdownDescription\": \"Denies the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-join\",\n          \"markdownDescription\": \"Denies the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-normalize\",\n          \"markdownDescription\": \"Denies the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve\",\n          \"markdownDescription\": \"Denies the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve-directory\",\n          \"markdownDescription\": \"Denies the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\"\n        },\n        {\n          \"description\": \"Enables the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-get-by-id\",\n          \"markdownDescription\": \"Enables the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-remove-by-id\",\n          \"markdownDescription\": \"Enables the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon-as-template\",\n          \"markdownDescription\": \"Enables the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-menu\",\n          \"markdownDescription\": \"Enables the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-temp-dir-path\",\n          \"markdownDescription\": \"Enables the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-tooltip\",\n          \"markdownDescription\": \"Enables the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-visible\",\n          \"markdownDescription\": \"Enables the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-get-by-id\",\n          \"markdownDescription\": \"Denies the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-remove-by-id\",\n          \"markdownDescription\": \"Denies the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon-as-template\",\n          \"markdownDescription\": \"Denies the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-menu\",\n          \"markdownDescription\": \"Denies the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-temp-dir-path\",\n          \"markdownDescription\": \"Denies the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-tooltip\",\n          \"markdownDescription\": \"Denies the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-visible\",\n          \"markdownDescription\": \"Denies the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\"\n        },\n        {\n          \"description\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-clear-all-browsing-data\",\n          \"markdownDescription\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview\",\n          \"markdownDescription\": \"Enables the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview-window\",\n          \"markdownDescription\": \"Enables the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-get-all-webviews\",\n          \"markdownDescription\": \"Enables the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-internal-toggle-devtools\",\n          \"markdownDescription\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-print\",\n          \"markdownDescription\": \"Enables the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-reparent\",\n          \"markdownDescription\": \"Enables the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-auto-resize\",\n          \"markdownDescription\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-background-color\",\n          \"markdownDescription\": \"Enables the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-focus\",\n          \"markdownDescription\": \"Enables the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-position\",\n          \"markdownDescription\": \"Enables the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-size\",\n          \"markdownDescription\": \"Enables the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-zoom\",\n          \"markdownDescription\": \"Enables the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-close\",\n          \"markdownDescription\": \"Enables the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-hide\",\n          \"markdownDescription\": \"Enables the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-position\",\n          \"markdownDescription\": \"Enables the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-show\",\n          \"markdownDescription\": \"Enables the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-size\",\n          \"markdownDescription\": \"Enables the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-clear-all-browsing-data\",\n          \"markdownDescription\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview\",\n          \"markdownDescription\": \"Denies the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview-window\",\n          \"markdownDescription\": \"Denies the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-get-all-webviews\",\n          \"markdownDescription\": \"Denies the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-internal-toggle-devtools\",\n          \"markdownDescription\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-print\",\n          \"markdownDescription\": \"Denies the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-reparent\",\n          \"markdownDescription\": \"Denies the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-auto-resize\",\n          \"markdownDescription\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-background-color\",\n          \"markdownDescription\": \"Denies the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-focus\",\n          \"markdownDescription\": \"Denies the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-position\",\n          \"markdownDescription\": \"Denies the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-size\",\n          \"markdownDescription\": \"Denies the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-zoom\",\n          \"markdownDescription\": \"Denies the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-close\",\n          \"markdownDescription\": \"Denies the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-hide\",\n          \"markdownDescription\": \"Denies the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-position\",\n          \"markdownDescription\": \"Denies the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-show\",\n          \"markdownDescription\": \"Denies the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-size\",\n          \"markdownDescription\": \"Denies the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\",\n          \"type\": \"string\",\n          \"const\": \"core:window:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\"\n        },\n        {\n          \"description\": \"Enables the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-available-monitors\",\n          \"markdownDescription\": \"Enables the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-center\",\n          \"markdownDescription\": \"Enables the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-create\",\n          \"markdownDescription\": \"Enables the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-current-monitor\",\n          \"markdownDescription\": \"Enables the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-cursor-position\",\n          \"markdownDescription\": \"Enables the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-destroy\",\n          \"markdownDescription\": \"Enables the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-get-all-windows\",\n          \"markdownDescription\": \"Enables the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-hide\",\n          \"markdownDescription\": \"Enables the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-position\",\n          \"markdownDescription\": \"Enables the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-size\",\n          \"markdownDescription\": \"Enables the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-internal-toggle-maximize\",\n          \"markdownDescription\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-always-on-top\",\n          \"markdownDescription\": \"Enables the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-closable\",\n          \"markdownDescription\": \"Enables the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-decorated\",\n          \"markdownDescription\": \"Enables the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-focused\",\n          \"markdownDescription\": \"Enables the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-fullscreen\",\n          \"markdownDescription\": \"Enables the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximizable\",\n          \"markdownDescription\": \"Enables the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximized\",\n          \"markdownDescription\": \"Enables the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimizable\",\n          \"markdownDescription\": \"Enables the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimized\",\n          \"markdownDescription\": \"Enables the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-resizable\",\n          \"markdownDescription\": \"Enables the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-visible\",\n          \"markdownDescription\": \"Enables the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-maximize\",\n          \"markdownDescription\": \"Enables the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-minimize\",\n          \"markdownDescription\": \"Enables the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-monitor-from-point\",\n          \"markdownDescription\": \"Enables the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-position\",\n          \"markdownDescription\": \"Enables the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-size\",\n          \"markdownDescription\": \"Enables the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-primary-monitor\",\n          \"markdownDescription\": \"Enables the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-request-user-attention\",\n          \"markdownDescription\": \"Enables the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-scale-factor\",\n          \"markdownDescription\": \"Enables the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-bottom\",\n          \"markdownDescription\": \"Enables the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-top\",\n          \"markdownDescription\": \"Enables the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-background-color\",\n          \"markdownDescription\": \"Enables the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-count\",\n          \"markdownDescription\": \"Enables the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-label\",\n          \"markdownDescription\": \"Enables the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-closable\",\n          \"markdownDescription\": \"Enables the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-content-protected\",\n          \"markdownDescription\": \"Enables the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-grab\",\n          \"markdownDescription\": \"Enables the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-icon\",\n          \"markdownDescription\": \"Enables the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-position\",\n          \"markdownDescription\": \"Enables the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-visible\",\n          \"markdownDescription\": \"Enables the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-decorations\",\n          \"markdownDescription\": \"Enables the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-effects\",\n          \"markdownDescription\": \"Enables the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focus\",\n          \"markdownDescription\": \"Enables the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focusable\",\n          \"markdownDescription\": \"Enables the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-fullscreen\",\n          \"markdownDescription\": \"Enables the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-max-size\",\n          \"markdownDescription\": \"Enables the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-maximizable\",\n          \"markdownDescription\": \"Enables the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-min-size\",\n          \"markdownDescription\": \"Enables the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-minimizable\",\n          \"markdownDescription\": \"Enables the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-overlay-icon\",\n          \"markdownDescription\": \"Enables the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-position\",\n          \"markdownDescription\": \"Enables the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-progress-bar\",\n          \"markdownDescription\": \"Enables the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-resizable\",\n          \"markdownDescription\": \"Enables the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-shadow\",\n          \"markdownDescription\": \"Enables the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-simple-fullscreen\",\n          \"markdownDescription\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size\",\n          \"markdownDescription\": \"Enables the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size-constraints\",\n          \"markdownDescription\": \"Enables the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-skip-taskbar\",\n          \"markdownDescription\": \"Enables the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-theme\",\n          \"markdownDescription\": \"Enables the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title-bar-style\",\n          \"markdownDescription\": \"Enables the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-dragging\",\n          \"markdownDescription\": \"Enables the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-resize-dragging\",\n          \"markdownDescription\": \"Enables the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-theme\",\n          \"markdownDescription\": \"Enables the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-title\",\n          \"markdownDescription\": \"Enables the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-toggle-maximize\",\n          \"markdownDescription\": \"Enables the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unmaximize\",\n          \"markdownDescription\": \"Enables the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unminimize\",\n          \"markdownDescription\": \"Enables the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-available-monitors\",\n          \"markdownDescription\": \"Denies the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-center\",\n          \"markdownDescription\": \"Denies the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-create\",\n          \"markdownDescription\": \"Denies the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-current-monitor\",\n          \"markdownDescription\": \"Denies the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-cursor-position\",\n          \"markdownDescription\": \"Denies the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-destroy\",\n          \"markdownDescription\": \"Denies the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-get-all-windows\",\n          \"markdownDescription\": \"Denies the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-hide\",\n          \"markdownDescription\": \"Denies the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-position\",\n          \"markdownDescription\": \"Denies the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-size\",\n          \"markdownDescription\": \"Denies the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-internal-toggle-maximize\",\n          \"markdownDescription\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-always-on-top\",\n          \"markdownDescription\": \"Denies the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-closable\",\n          \"markdownDescription\": \"Denies the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-decorated\",\n          \"markdownDescription\": \"Denies the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-focused\",\n          \"markdownDescription\": \"Denies the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-fullscreen\",\n          \"markdownDescription\": \"Denies the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximizable\",\n          \"markdownDescription\": \"Denies the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximized\",\n          \"markdownDescription\": \"Denies the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimizable\",\n          \"markdownDescription\": \"Denies the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimized\",\n          \"markdownDescription\": \"Denies the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-resizable\",\n          \"markdownDescription\": \"Denies the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-visible\",\n          \"markdownDescription\": \"Denies the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-maximize\",\n          \"markdownDescription\": \"Denies the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-minimize\",\n          \"markdownDescription\": \"Denies the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-monitor-from-point\",\n          \"markdownDescription\": \"Denies the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-position\",\n          \"markdownDescription\": \"Denies the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-size\",\n          \"markdownDescription\": \"Denies the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-primary-monitor\",\n          \"markdownDescription\": \"Denies the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-request-user-attention\",\n          \"markdownDescription\": \"Denies the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-scale-factor\",\n          \"markdownDescription\": \"Denies the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-bottom\",\n          \"markdownDescription\": \"Denies the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-top\",\n          \"markdownDescription\": \"Denies the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-background-color\",\n          \"markdownDescription\": \"Denies the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-count\",\n          \"markdownDescription\": \"Denies the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-label\",\n          \"markdownDescription\": \"Denies the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-closable\",\n          \"markdownDescription\": \"Denies the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-content-protected\",\n          \"markdownDescription\": \"Denies the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-grab\",\n          \"markdownDescription\": \"Denies the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-icon\",\n          \"markdownDescription\": \"Denies the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-position\",\n          \"markdownDescription\": \"Denies the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-visible\",\n          \"markdownDescription\": \"Denies the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-decorations\",\n          \"markdownDescription\": \"Denies the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-effects\",\n          \"markdownDescription\": \"Denies the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focus\",\n          \"markdownDescription\": \"Denies the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focusable\",\n          \"markdownDescription\": \"Denies the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-fullscreen\",\n          \"markdownDescription\": \"Denies the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-max-size\",\n          \"markdownDescription\": \"Denies the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-maximizable\",\n          \"markdownDescription\": \"Denies the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-min-size\",\n          \"markdownDescription\": \"Denies the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-minimizable\",\n          \"markdownDescription\": \"Denies the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-overlay-icon\",\n          \"markdownDescription\": \"Denies the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-position\",\n          \"markdownDescription\": \"Denies the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-progress-bar\",\n          \"markdownDescription\": \"Denies the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-resizable\",\n          \"markdownDescription\": \"Denies the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-shadow\",\n          \"markdownDescription\": \"Denies the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-simple-fullscreen\",\n          \"markdownDescription\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size\",\n          \"markdownDescription\": \"Denies the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size-constraints\",\n          \"markdownDescription\": \"Denies the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-skip-taskbar\",\n          \"markdownDescription\": \"Denies the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-theme\",\n          \"markdownDescription\": \"Denies the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title-bar-style\",\n          \"markdownDescription\": \"Denies the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-dragging\",\n          \"markdownDescription\": \"Denies the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-resize-dragging\",\n          \"markdownDescription\": \"Denies the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-theme\",\n          \"markdownDescription\": \"Denies the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-title\",\n          \"markdownDescription\": \"Denies the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-toggle-maximize\",\n          \"markdownDescription\": \"Denies the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unmaximize\",\n          \"markdownDescription\": \"Denies the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unminimize\",\n          \"markdownDescription\": \"Denies the unminimize command without any pre-configured scope.\"\n        }\n      ]\n    },\n    \"Value\": {\n      \"description\": \"All supported ACL values.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents a null JSON value.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"Represents a [`bool`].\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Represents a valid ACL [`Number`].\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Number\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Represents a [`String`].\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"Represents a list of other [`Value`]s.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        },\n        {\n          \"description\": \"Represents a map of [`String`] keys to [`Value`]s.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        }\n      ]\n    },\n    \"Number\": {\n      \"description\": \"A valid ACL number.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents an [`i64`].\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        {\n          \"description\": \"Represents a [`f64`].\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      ]\n    },\n    \"Target\": {\n      \"description\": \"Platform target.\",\n      \"oneOf\": [\n        {\n          \"description\": \"MacOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"macOS\"\n          ]\n        },\n        {\n          \"description\": \"Windows.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"windows\"\n          ]\n        },\n        {\n          \"description\": \"Linux.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"linux\"\n          ]\n        },\n        {\n          \"description\": \"Android.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"android\"\n          ]\n        },\n        {\n          \"description\": \"iOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iOS\"\n          ]\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "desktop-pet/src-tauri/gen/schemas/macOS-schema.json",
    "content": "{\n  \"$schema\": \"http://json-schema.org/draft-07/schema#\",\n  \"title\": \"CapabilityFile\",\n  \"description\": \"Capability formats accepted in a capability file.\",\n  \"anyOf\": [\n    {\n      \"description\": \"A single capability.\",\n      \"allOf\": [\n        {\n          \"$ref\": \"#/definitions/Capability\"\n        }\n      ]\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"array\",\n      \"items\": {\n        \"$ref\": \"#/definitions/Capability\"\n      }\n    },\n    {\n      \"description\": \"A list of capabilities.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"capabilities\"\n      ],\n      \"properties\": {\n        \"capabilities\": {\n          \"description\": \"The list of capabilities.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Capability\"\n          }\n        }\n      }\n    }\n  ],\n  \"definitions\": {\n    \"Capability\": {\n      \"description\": \"A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\\n\\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\\n\\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\\n\\n## Example\\n\\n```json { \\\"identifier\\\": \\\"main-user-files-write\\\", \\\"description\\\": \\\"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\\\", \\\"windows\\\": [ \\\"main\\\" ], \\\"permissions\\\": [ \\\"core:default\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] }, ], \\\"platforms\\\": [\\\"macOS\\\",\\\"windows\\\"] } ```\",\n      \"type\": \"object\",\n      \"required\": [\n        \"identifier\",\n        \"permissions\"\n      ],\n      \"properties\": {\n        \"identifier\": {\n          \"description\": \"Identifier of the capability.\\n\\n## Example\\n\\n`main-user-files-write`\",\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"description\": \"Description of what the capability is intended to allow on associated windows.\\n\\nIt should contain a description of what the grouped permissions should allow.\\n\\n## Example\\n\\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\",\n          \"default\": \"\",\n          \"type\": \"string\"\n        },\n        \"remote\": {\n          \"description\": \"Configure remote URLs that can use the capability permissions.\\n\\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\\n\\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\\n\\n## Example\\n\\n```json { \\\"urls\\\": [\\\"https://*.mydomain.dev\\\"] } ```\",\n          \"anyOf\": [\n            {\n              \"$ref\": \"#/definitions/CapabilityRemote\"\n            },\n            {\n              \"type\": \"null\"\n            }\n          ]\n        },\n        \"local\": {\n          \"description\": \"Whether this capability is enabled for local app URLs or not. Defaults to `true`.\",\n          \"default\": true,\n          \"type\": \"boolean\"\n        },\n        \"windows\": {\n          \"description\": \"List of windows that are affected by this capability. Can be a glob pattern.\\n\\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\\n\\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\\n\\n## Example\\n\\n`[\\\"main\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"webviews\": {\n          \"description\": \"List of webviews that are affected by this capability. Can be a glob pattern.\\n\\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\\n\\n## Example\\n\\n`[\\\"sub-webview-one\\\", \\\"sub-webview-two\\\"]`\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"permissions\": {\n          \"description\": \"List of permissions attached to this capability.\\n\\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\\n\\n## Example\\n\\n```json [ \\\"core:default\\\", \\\"shell:allow-open\\\", \\\"dialog:open\\\", { \\\"identifier\\\": \\\"fs:allow-write-text-file\\\", \\\"allow\\\": [{ \\\"path\\\": \\\"$HOME/test.txt\\\" }] } ] ```\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/PermissionEntry\"\n          },\n          \"uniqueItems\": true\n        },\n        \"platforms\": {\n          \"description\": \"Limit which target platforms this capability applies to.\\n\\nBy default all platforms are targeted.\\n\\n## Example\\n\\n`[\\\"macOS\\\",\\\"windows\\\"]`\",\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"$ref\": \"#/definitions/Target\"\n          }\n        }\n      }\n    },\n    \"CapabilityRemote\": {\n      \"description\": \"Configuration for remote URLs that are associated with the capability.\",\n      \"type\": \"object\",\n      \"required\": [\n        \"urls\"\n      ],\n      \"properties\": {\n        \"urls\": {\n          \"description\": \"Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\\n\\n## Examples\\n\\n- \\\"https://*.mydomain.dev\\\": allows subdomains of mydomain.dev - \\\"https://mydomain.dev/api/*\\\": allows any subpath of mydomain.dev/api\",\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        }\n      }\n    },\n    \"PermissionEntry\": {\n      \"description\": \"An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Reference a permission or permission set by identifier.\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Identifier\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Reference a permission or permission set by identifier and extends its scope.\",\n          \"type\": \"object\",\n          \"allOf\": [\n            {\n              \"properties\": {\n                \"identifier\": {\n                  \"description\": \"Identifier of the permission or permission set.\",\n                  \"allOf\": [\n                    {\n                      \"$ref\": \"#/definitions/Identifier\"\n                    }\n                  ]\n                },\n                \"allow\": {\n                  \"description\": \"Data that defines what is allowed by the scope.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                },\n                \"deny\": {\n                  \"description\": \"Data that defines what is denied by the scope. This should be prioritized by validation logic.\",\n                  \"type\": [\n                    \"array\",\n                    \"null\"\n                  ],\n                  \"items\": {\n                    \"$ref\": \"#/definitions/Value\"\n                  }\n                }\n              }\n            }\n          ],\n          \"required\": [\n            \"identifier\"\n          ]\n        }\n      ]\n    },\n    \"Identifier\": {\n      \"description\": \"Permission identifier\",\n      \"oneOf\": [\n        {\n          \"description\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\",\n          \"type\": \"string\",\n          \"const\": \"core:default\",\n          \"markdownDescription\": \"Default core plugins set.\\n#### This default permission set includes:\\n\\n- `core:path:default`\\n- `core:event:default`\\n- `core:window:default`\\n- `core:webview:default`\\n- `core:app:default`\\n- `core:image:default`\\n- `core:resources:default`\\n- `core:menu:default`\\n- `core:tray:default`\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\",\n          \"type\": \"string\",\n          \"const\": \"core:app:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-version`\\n- `allow-name`\\n- `allow-tauri-version`\\n- `allow-identifier`\\n- `allow-bundle-type`\\n- `allow-register-listener`\\n- `allow-remove-listener`\"\n        },\n        {\n          \"description\": \"Enables the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-hide\",\n          \"markdownDescription\": \"Enables the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-app-show\",\n          \"markdownDescription\": \"Enables the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-bundle-type\",\n          \"markdownDescription\": \"Enables the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-default-window-icon\",\n          \"markdownDescription\": \"Enables the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Enables the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-identifier\",\n          \"markdownDescription\": \"Enables the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-name\",\n          \"markdownDescription\": \"Enables the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-register-listener\",\n          \"markdownDescription\": \"Enables the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-data-store\",\n          \"markdownDescription\": \"Enables the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-remove-listener\",\n          \"markdownDescription\": \"Enables the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-app-theme\",\n          \"markdownDescription\": \"Enables the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-set-dock-visibility\",\n          \"markdownDescription\": \"Enables the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-tauri-version\",\n          \"markdownDescription\": \"Enables the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:allow-version\",\n          \"markdownDescription\": \"Enables the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-hide\",\n          \"markdownDescription\": \"Denies the app_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the app_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-app-show\",\n          \"markdownDescription\": \"Denies the app_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the bundle_type command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-bundle-type\",\n          \"markdownDescription\": \"Denies the bundle_type command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the default_window_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-default-window-icon\",\n          \"markdownDescription\": \"Denies the default_window_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-fetch-data-store-identifiers\",\n          \"markdownDescription\": \"Denies the fetch_data_store_identifiers command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the identifier command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-identifier\",\n          \"markdownDescription\": \"Denies the identifier command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the name command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-name\",\n          \"markdownDescription\": \"Denies the name command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the register_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-register-listener\",\n          \"markdownDescription\": \"Denies the register_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_data_store command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-data-store\",\n          \"markdownDescription\": \"Denies the remove_data_store command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_listener command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-remove-listener\",\n          \"markdownDescription\": \"Denies the remove_listener command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_app_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-app-theme\",\n          \"markdownDescription\": \"Denies the set_app_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_dock_visibility command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-set-dock-visibility\",\n          \"markdownDescription\": \"Denies the set_dock_visibility command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the tauri_version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-tauri-version\",\n          \"markdownDescription\": \"Denies the tauri_version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the version command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:app:deny-version\",\n          \"markdownDescription\": \"Denies the version command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\",\n          \"type\": \"string\",\n          \"const\": \"core:event:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-listen`\\n- `allow-unlisten`\\n- `allow-emit`\\n- `allow-emit-to`\"\n        },\n        {\n          \"description\": \"Enables the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit\",\n          \"markdownDescription\": \"Enables the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-emit-to\",\n          \"markdownDescription\": \"Enables the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-listen\",\n          \"markdownDescription\": \"Enables the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:allow-unlisten\",\n          \"markdownDescription\": \"Enables the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit\",\n          \"markdownDescription\": \"Denies the emit command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the emit_to command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-emit-to\",\n          \"markdownDescription\": \"Denies the emit_to command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the listen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-listen\",\n          \"markdownDescription\": \"Denies the listen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unlisten command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:event:deny-unlisten\",\n          \"markdownDescription\": \"Denies the unlisten command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\",\n          \"type\": \"string\",\n          \"const\": \"core:image:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-from-bytes`\\n- `allow-from-path`\\n- `allow-rgba`\\n- `allow-size`\"\n        },\n        {\n          \"description\": \"Enables the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-bytes\",\n          \"markdownDescription\": \"Enables the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-from-path\",\n          \"markdownDescription\": \"Enables the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-rgba\",\n          \"markdownDescription\": \"Enables the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:allow-size\",\n          \"markdownDescription\": \"Enables the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_bytes command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-bytes\",\n          \"markdownDescription\": \"Denies the from_bytes command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the from_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-from-path\",\n          \"markdownDescription\": \"Denies the from_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the rgba command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-rgba\",\n          \"markdownDescription\": \"Denies the rgba command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:image:deny-size\",\n          \"markdownDescription\": \"Denies the size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-append`\\n- `allow-prepend`\\n- `allow-insert`\\n- `allow-remove`\\n- `allow-remove-at`\\n- `allow-items`\\n- `allow-get`\\n- `allow-popup`\\n- `allow-create-default`\\n- `allow-set-as-app-menu`\\n- `allow-set-as-window-menu`\\n- `allow-text`\\n- `allow-set-text`\\n- `allow-is-enabled`\\n- `allow-set-enabled`\\n- `allow-set-accelerator`\\n- `allow-set-as-windows-menu-for-nsapp`\\n- `allow-set-as-help-menu-for-nsapp`\\n- `allow-is-checked`\\n- `allow-set-checked`\\n- `allow-set-icon`\"\n        },\n        {\n          \"description\": \"Enables the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-append\",\n          \"markdownDescription\": \"Enables the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-create-default\",\n          \"markdownDescription\": \"Enables the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-get\",\n          \"markdownDescription\": \"Enables the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-insert\",\n          \"markdownDescription\": \"Enables the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-checked\",\n          \"markdownDescription\": \"Enables the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-items\",\n          \"markdownDescription\": \"Enables the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-popup\",\n          \"markdownDescription\": \"Enables the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-prepend\",\n          \"markdownDescription\": \"Enables the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove\",\n          \"markdownDescription\": \"Enables the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-remove-at\",\n          \"markdownDescription\": \"Enables the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-accelerator\",\n          \"markdownDescription\": \"Enables the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-app-menu\",\n          \"markdownDescription\": \"Enables the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-window-menu\",\n          \"markdownDescription\": \"Enables the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-checked\",\n          \"markdownDescription\": \"Enables the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-set-text\",\n          \"markdownDescription\": \"Enables the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:allow-text\",\n          \"markdownDescription\": \"Enables the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the append command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-append\",\n          \"markdownDescription\": \"Denies the append command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_default command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-create-default\",\n          \"markdownDescription\": \"Denies the create_default command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-get\",\n          \"markdownDescription\": \"Denies the get command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the insert command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-insert\",\n          \"markdownDescription\": \"Denies the insert command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-checked\",\n          \"markdownDescription\": \"Denies the is_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the items command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-items\",\n          \"markdownDescription\": \"Denies the items command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the popup command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-popup\",\n          \"markdownDescription\": \"Denies the popup command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the prepend command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-prepend\",\n          \"markdownDescription\": \"Denies the prepend command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove\",\n          \"markdownDescription\": \"Denies the remove command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_at command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-remove-at\",\n          \"markdownDescription\": \"Denies the remove_at command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_accelerator command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-accelerator\",\n          \"markdownDescription\": \"Denies the set_accelerator command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_app_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-app-menu\",\n          \"markdownDescription\": \"Denies the set_as_app_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-help-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_window_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-window-menu\",\n          \"markdownDescription\": \"Denies the set_as_window_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-as-windows-menu-for-nsapp\",\n          \"markdownDescription\": \"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_checked command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-checked\",\n          \"markdownDescription\": \"Denies the set_checked command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-set-text\",\n          \"markdownDescription\": \"Denies the set_text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the text command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:menu:deny-text\",\n          \"markdownDescription\": \"Denies the text command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\",\n          \"type\": \"string\",\n          \"const\": \"core:path:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-resolve-directory`\\n- `allow-resolve`\\n- `allow-normalize`\\n- `allow-join`\\n- `allow-dirname`\\n- `allow-extname`\\n- `allow-basename`\\n- `allow-is-absolute`\"\n        },\n        {\n          \"description\": \"Enables the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-basename\",\n          \"markdownDescription\": \"Enables the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-dirname\",\n          \"markdownDescription\": \"Enables the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-extname\",\n          \"markdownDescription\": \"Enables the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-is-absolute\",\n          \"markdownDescription\": \"Enables the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-join\",\n          \"markdownDescription\": \"Enables the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-normalize\",\n          \"markdownDescription\": \"Enables the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve\",\n          \"markdownDescription\": \"Enables the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:allow-resolve-directory\",\n          \"markdownDescription\": \"Enables the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the basename command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-basename\",\n          \"markdownDescription\": \"Denies the basename command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the dirname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-dirname\",\n          \"markdownDescription\": \"Denies the dirname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the extname command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-extname\",\n          \"markdownDescription\": \"Denies the extname command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_absolute command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-is-absolute\",\n          \"markdownDescription\": \"Denies the is_absolute command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the join command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-join\",\n          \"markdownDescription\": \"Denies the join command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the normalize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-normalize\",\n          \"markdownDescription\": \"Denies the normalize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve\",\n          \"markdownDescription\": \"Denies the resolve command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the resolve_directory command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:path:deny-resolve-directory\",\n          \"markdownDescription\": \"Denies the resolve_directory command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-close`\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:resources:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:default\",\n          \"markdownDescription\": \"Default permissions for the plugin, which enables all commands.\\n#### This default permission set includes:\\n\\n- `allow-new`\\n- `allow-get-by-id`\\n- `allow-remove-by-id`\\n- `allow-set-icon`\\n- `allow-set-menu`\\n- `allow-set-tooltip`\\n- `allow-set-title`\\n- `allow-set-visible`\\n- `allow-set-temp-dir-path`\\n- `allow-set-icon-as-template`\\n- `allow-set-show-menu-on-left-click`\"\n        },\n        {\n          \"description\": \"Enables the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-get-by-id\",\n          \"markdownDescription\": \"Enables the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-new\",\n          \"markdownDescription\": \"Enables the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-remove-by-id\",\n          \"markdownDescription\": \"Enables the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-icon-as-template\",\n          \"markdownDescription\": \"Enables the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-menu\",\n          \"markdownDescription\": \"Enables the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Enables the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-temp-dir-path\",\n          \"markdownDescription\": \"Enables the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-tooltip\",\n          \"markdownDescription\": \"Enables the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:allow-set-visible\",\n          \"markdownDescription\": \"Enables the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-get-by-id\",\n          \"markdownDescription\": \"Denies the get_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the new command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-new\",\n          \"markdownDescription\": \"Denies the new command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the remove_by_id command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-remove-by-id\",\n          \"markdownDescription\": \"Denies the remove_by_id command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon_as_template command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-icon-as-template\",\n          \"markdownDescription\": \"Denies the set_icon_as_template command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_menu command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-menu\",\n          \"markdownDescription\": \"Denies the set_menu command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-show-menu-on-left-click\",\n          \"markdownDescription\": \"Denies the set_show_menu_on_left_click command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_temp_dir_path command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-temp-dir-path\",\n          \"markdownDescription\": \"Denies the set_temp_dir_path command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_tooltip command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-tooltip\",\n          \"markdownDescription\": \"Denies the set_tooltip command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:tray:deny-set-visible\",\n          \"markdownDescription\": \"Denies the set_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-webviews`\\n- `allow-webview-position`\\n- `allow-webview-size`\\n- `allow-internal-toggle-devtools`\"\n        },\n        {\n          \"description\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-clear-all-browsing-data\",\n          \"markdownDescription\": \"Enables the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview\",\n          \"markdownDescription\": \"Enables the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-create-webview-window\",\n          \"markdownDescription\": \"Enables the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-get-all-webviews\",\n          \"markdownDescription\": \"Enables the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-internal-toggle-devtools\",\n          \"markdownDescription\": \"Enables the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-print\",\n          \"markdownDescription\": \"Enables the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-reparent\",\n          \"markdownDescription\": \"Enables the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-auto-resize\",\n          \"markdownDescription\": \"Enables the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-background-color\",\n          \"markdownDescription\": \"Enables the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-focus\",\n          \"markdownDescription\": \"Enables the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-position\",\n          \"markdownDescription\": \"Enables the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-size\",\n          \"markdownDescription\": \"Enables the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-set-webview-zoom\",\n          \"markdownDescription\": \"Enables the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-close\",\n          \"markdownDescription\": \"Enables the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-hide\",\n          \"markdownDescription\": \"Enables the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-position\",\n          \"markdownDescription\": \"Enables the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-show\",\n          \"markdownDescription\": \"Enables the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:allow-webview-size\",\n          \"markdownDescription\": \"Enables the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-clear-all-browsing-data\",\n          \"markdownDescription\": \"Denies the clear_all_browsing_data command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview\",\n          \"markdownDescription\": \"Denies the create_webview command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create_webview_window command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-create-webview-window\",\n          \"markdownDescription\": \"Denies the create_webview_window command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_webviews command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-get-all-webviews\",\n          \"markdownDescription\": \"Denies the get_all_webviews command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-internal-toggle-devtools\",\n          \"markdownDescription\": \"Denies the internal_toggle_devtools command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the print command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-print\",\n          \"markdownDescription\": \"Denies the print command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the reparent command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-reparent\",\n          \"markdownDescription\": \"Denies the reparent command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-auto-resize\",\n          \"markdownDescription\": \"Denies the set_webview_auto_resize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-background-color\",\n          \"markdownDescription\": \"Denies the set_webview_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-focus\",\n          \"markdownDescription\": \"Denies the set_webview_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-position\",\n          \"markdownDescription\": \"Denies the set_webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-size\",\n          \"markdownDescription\": \"Denies the set_webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_webview_zoom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-set-webview-zoom\",\n          \"markdownDescription\": \"Denies the set_webview_zoom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-close\",\n          \"markdownDescription\": \"Denies the webview_close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-hide\",\n          \"markdownDescription\": \"Denies the webview_hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-position\",\n          \"markdownDescription\": \"Denies the webview_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-show\",\n          \"markdownDescription\": \"Denies the webview_show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the webview_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:webview:deny-webview-size\",\n          \"markdownDescription\": \"Denies the webview_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\",\n          \"type\": \"string\",\n          \"const\": \"core:window:default\",\n          \"markdownDescription\": \"Default permissions for the plugin.\\n#### This default permission set includes:\\n\\n- `allow-get-all-windows`\\n- `allow-scale-factor`\\n- `allow-inner-position`\\n- `allow-outer-position`\\n- `allow-inner-size`\\n- `allow-outer-size`\\n- `allow-is-fullscreen`\\n- `allow-is-minimized`\\n- `allow-is-maximized`\\n- `allow-is-focused`\\n- `allow-is-decorated`\\n- `allow-is-resizable`\\n- `allow-is-maximizable`\\n- `allow-is-minimizable`\\n- `allow-is-closable`\\n- `allow-is-visible`\\n- `allow-is-enabled`\\n- `allow-title`\\n- `allow-current-monitor`\\n- `allow-primary-monitor`\\n- `allow-monitor-from-point`\\n- `allow-available-monitors`\\n- `allow-cursor-position`\\n- `allow-theme`\\n- `allow-is-always-on-top`\\n- `allow-internal-toggle-maximize`\"\n        },\n        {\n          \"description\": \"Enables the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-available-monitors\",\n          \"markdownDescription\": \"Enables the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-center\",\n          \"markdownDescription\": \"Enables the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-close\",\n          \"markdownDescription\": \"Enables the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-create\",\n          \"markdownDescription\": \"Enables the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-current-monitor\",\n          \"markdownDescription\": \"Enables the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-cursor-position\",\n          \"markdownDescription\": \"Enables the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-destroy\",\n          \"markdownDescription\": \"Enables the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-get-all-windows\",\n          \"markdownDescription\": \"Enables the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-hide\",\n          \"markdownDescription\": \"Enables the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-position\",\n          \"markdownDescription\": \"Enables the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-inner-size\",\n          \"markdownDescription\": \"Enables the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-internal-toggle-maximize\",\n          \"markdownDescription\": \"Enables the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-always-on-top\",\n          \"markdownDescription\": \"Enables the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-closable\",\n          \"markdownDescription\": \"Enables the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-decorated\",\n          \"markdownDescription\": \"Enables the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-enabled\",\n          \"markdownDescription\": \"Enables the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-focused\",\n          \"markdownDescription\": \"Enables the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-fullscreen\",\n          \"markdownDescription\": \"Enables the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximizable\",\n          \"markdownDescription\": \"Enables the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-maximized\",\n          \"markdownDescription\": \"Enables the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimizable\",\n          \"markdownDescription\": \"Enables the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-minimized\",\n          \"markdownDescription\": \"Enables the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-resizable\",\n          \"markdownDescription\": \"Enables the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-is-visible\",\n          \"markdownDescription\": \"Enables the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-maximize\",\n          \"markdownDescription\": \"Enables the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-minimize\",\n          \"markdownDescription\": \"Enables the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-monitor-from-point\",\n          \"markdownDescription\": \"Enables the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-position\",\n          \"markdownDescription\": \"Enables the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-outer-size\",\n          \"markdownDescription\": \"Enables the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-primary-monitor\",\n          \"markdownDescription\": \"Enables the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-request-user-attention\",\n          \"markdownDescription\": \"Enables the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-scale-factor\",\n          \"markdownDescription\": \"Enables the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-bottom\",\n          \"markdownDescription\": \"Enables the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-always-on-top\",\n          \"markdownDescription\": \"Enables the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-background-color\",\n          \"markdownDescription\": \"Enables the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-count\",\n          \"markdownDescription\": \"Enables the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-badge-label\",\n          \"markdownDescription\": \"Enables the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-closable\",\n          \"markdownDescription\": \"Enables the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-content-protected\",\n          \"markdownDescription\": \"Enables the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-grab\",\n          \"markdownDescription\": \"Enables the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-icon\",\n          \"markdownDescription\": \"Enables the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-position\",\n          \"markdownDescription\": \"Enables the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-cursor-visible\",\n          \"markdownDescription\": \"Enables the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-decorations\",\n          \"markdownDescription\": \"Enables the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-effects\",\n          \"markdownDescription\": \"Enables the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-enabled\",\n          \"markdownDescription\": \"Enables the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focus\",\n          \"markdownDescription\": \"Enables the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-focusable\",\n          \"markdownDescription\": \"Enables the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-fullscreen\",\n          \"markdownDescription\": \"Enables the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-icon\",\n          \"markdownDescription\": \"Enables the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Enables the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-max-size\",\n          \"markdownDescription\": \"Enables the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-maximizable\",\n          \"markdownDescription\": \"Enables the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-min-size\",\n          \"markdownDescription\": \"Enables the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-minimizable\",\n          \"markdownDescription\": \"Enables the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-overlay-icon\",\n          \"markdownDescription\": \"Enables the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-position\",\n          \"markdownDescription\": \"Enables the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-progress-bar\",\n          \"markdownDescription\": \"Enables the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-resizable\",\n          \"markdownDescription\": \"Enables the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-shadow\",\n          \"markdownDescription\": \"Enables the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-simple-fullscreen\",\n          \"markdownDescription\": \"Enables the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size\",\n          \"markdownDescription\": \"Enables the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-size-constraints\",\n          \"markdownDescription\": \"Enables the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-skip-taskbar\",\n          \"markdownDescription\": \"Enables the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-theme\",\n          \"markdownDescription\": \"Enables the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title\",\n          \"markdownDescription\": \"Enables the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-title-bar-style\",\n          \"markdownDescription\": \"Enables the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Enables the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-show\",\n          \"markdownDescription\": \"Enables the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-dragging\",\n          \"markdownDescription\": \"Enables the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-start-resize-dragging\",\n          \"markdownDescription\": \"Enables the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-theme\",\n          \"markdownDescription\": \"Enables the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-title\",\n          \"markdownDescription\": \"Enables the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-toggle-maximize\",\n          \"markdownDescription\": \"Enables the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unmaximize\",\n          \"markdownDescription\": \"Enables the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Enables the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:allow-unminimize\",\n          \"markdownDescription\": \"Enables the unminimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the available_monitors command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-available-monitors\",\n          \"markdownDescription\": \"Denies the available_monitors command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the center command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-center\",\n          \"markdownDescription\": \"Denies the center command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the close command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-close\",\n          \"markdownDescription\": \"Denies the close command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the create command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-create\",\n          \"markdownDescription\": \"Denies the create command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the current_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-current-monitor\",\n          \"markdownDescription\": \"Denies the current_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-cursor-position\",\n          \"markdownDescription\": \"Denies the cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the destroy command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-destroy\",\n          \"markdownDescription\": \"Denies the destroy command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the get_all_windows command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-get-all-windows\",\n          \"markdownDescription\": \"Denies the get_all_windows command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the hide command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-hide\",\n          \"markdownDescription\": \"Denies the hide command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-position\",\n          \"markdownDescription\": \"Denies the inner_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the inner_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-inner-size\",\n          \"markdownDescription\": \"Denies the inner_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-internal-toggle-maximize\",\n          \"markdownDescription\": \"Denies the internal_toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-always-on-top\",\n          \"markdownDescription\": \"Denies the is_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-closable\",\n          \"markdownDescription\": \"Denies the is_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_decorated command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-decorated\",\n          \"markdownDescription\": \"Denies the is_decorated command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-enabled\",\n          \"markdownDescription\": \"Denies the is_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_focused command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-focused\",\n          \"markdownDescription\": \"Denies the is_focused command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-fullscreen\",\n          \"markdownDescription\": \"Denies the is_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximizable\",\n          \"markdownDescription\": \"Denies the is_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_maximized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-maximized\",\n          \"markdownDescription\": \"Denies the is_maximized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimizable\",\n          \"markdownDescription\": \"Denies the is_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_minimized command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-minimized\",\n          \"markdownDescription\": \"Denies the is_minimized command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-resizable\",\n          \"markdownDescription\": \"Denies the is_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the is_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-is-visible\",\n          \"markdownDescription\": \"Denies the is_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-maximize\",\n          \"markdownDescription\": \"Denies the maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the minimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-minimize\",\n          \"markdownDescription\": \"Denies the minimize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the monitor_from_point command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-monitor-from-point\",\n          \"markdownDescription\": \"Denies the monitor_from_point command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-position\",\n          \"markdownDescription\": \"Denies the outer_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the outer_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-outer-size\",\n          \"markdownDescription\": \"Denies the outer_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the primary_monitor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-primary-monitor\",\n          \"markdownDescription\": \"Denies the primary_monitor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the request_user_attention command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-request-user-attention\",\n          \"markdownDescription\": \"Denies the request_user_attention command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the scale_factor command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-scale-factor\",\n          \"markdownDescription\": \"Denies the scale_factor command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_bottom command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-bottom\",\n          \"markdownDescription\": \"Denies the set_always_on_bottom command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_always_on_top command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-always-on-top\",\n          \"markdownDescription\": \"Denies the set_always_on_top command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_background_color command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-background-color\",\n          \"markdownDescription\": \"Denies the set_background_color command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_count command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-count\",\n          \"markdownDescription\": \"Denies the set_badge_count command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_badge_label command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-badge-label\",\n          \"markdownDescription\": \"Denies the set_badge_label command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_closable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-closable\",\n          \"markdownDescription\": \"Denies the set_closable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_content_protected command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-content-protected\",\n          \"markdownDescription\": \"Denies the set_content_protected command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_grab command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-grab\",\n          \"markdownDescription\": \"Denies the set_cursor_grab command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-icon\",\n          \"markdownDescription\": \"Denies the set_cursor_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-position\",\n          \"markdownDescription\": \"Denies the set_cursor_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_cursor_visible command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-cursor-visible\",\n          \"markdownDescription\": \"Denies the set_cursor_visible command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_decorations command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-decorations\",\n          \"markdownDescription\": \"Denies the set_decorations command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_effects command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-effects\",\n          \"markdownDescription\": \"Denies the set_effects command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_enabled command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-enabled\",\n          \"markdownDescription\": \"Denies the set_enabled command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focus command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focus\",\n          \"markdownDescription\": \"Denies the set_focus command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_focusable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-focusable\",\n          \"markdownDescription\": \"Denies the set_focusable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-fullscreen\",\n          \"markdownDescription\": \"Denies the set_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-icon\",\n          \"markdownDescription\": \"Denies the set_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-ignore-cursor-events\",\n          \"markdownDescription\": \"Denies the set_ignore_cursor_events command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_max_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-max-size\",\n          \"markdownDescription\": \"Denies the set_max_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_maximizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-maximizable\",\n          \"markdownDescription\": \"Denies the set_maximizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_min_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-min-size\",\n          \"markdownDescription\": \"Denies the set_min_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_minimizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-minimizable\",\n          \"markdownDescription\": \"Denies the set_minimizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_overlay_icon command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-overlay-icon\",\n          \"markdownDescription\": \"Denies the set_overlay_icon command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_position command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-position\",\n          \"markdownDescription\": \"Denies the set_position command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_progress_bar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-progress-bar\",\n          \"markdownDescription\": \"Denies the set_progress_bar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_resizable command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-resizable\",\n          \"markdownDescription\": \"Denies the set_resizable command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_shadow command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-shadow\",\n          \"markdownDescription\": \"Denies the set_shadow command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-simple-fullscreen\",\n          \"markdownDescription\": \"Denies the set_simple_fullscreen command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size\",\n          \"markdownDescription\": \"Denies the set_size command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_size_constraints command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-size-constraints\",\n          \"markdownDescription\": \"Denies the set_size_constraints command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_skip_taskbar command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-skip-taskbar\",\n          \"markdownDescription\": \"Denies the set_skip_taskbar command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-theme\",\n          \"markdownDescription\": \"Denies the set_theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title\",\n          \"markdownDescription\": \"Denies the set_title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_title_bar_style command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-title-bar-style\",\n          \"markdownDescription\": \"Denies the set_title_bar_style command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-set-visible-on-all-workspaces\",\n          \"markdownDescription\": \"Denies the set_visible_on_all_workspaces command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the show command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-show\",\n          \"markdownDescription\": \"Denies the show command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-dragging\",\n          \"markdownDescription\": \"Denies the start_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the start_resize_dragging command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-start-resize-dragging\",\n          \"markdownDescription\": \"Denies the start_resize_dragging command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the theme command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-theme\",\n          \"markdownDescription\": \"Denies the theme command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the title command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-title\",\n          \"markdownDescription\": \"Denies the title command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the toggle_maximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-toggle-maximize\",\n          \"markdownDescription\": \"Denies the toggle_maximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unmaximize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unmaximize\",\n          \"markdownDescription\": \"Denies the unmaximize command without any pre-configured scope.\"\n        },\n        {\n          \"description\": \"Denies the unminimize command without any pre-configured scope.\",\n          \"type\": \"string\",\n          \"const\": \"core:window:deny-unminimize\",\n          \"markdownDescription\": \"Denies the unminimize command without any pre-configured scope.\"\n        }\n      ]\n    },\n    \"Value\": {\n      \"description\": \"All supported ACL values.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents a null JSON value.\",\n          \"type\": \"null\"\n        },\n        {\n          \"description\": \"Represents a [`bool`].\",\n          \"type\": \"boolean\"\n        },\n        {\n          \"description\": \"Represents a valid ACL [`Number`].\",\n          \"allOf\": [\n            {\n              \"$ref\": \"#/definitions/Number\"\n            }\n          ]\n        },\n        {\n          \"description\": \"Represents a [`String`].\",\n          \"type\": \"string\"\n        },\n        {\n          \"description\": \"Represents a list of other [`Value`]s.\",\n          \"type\": \"array\",\n          \"items\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        },\n        {\n          \"description\": \"Represents a map of [`String`] keys to [`Value`]s.\",\n          \"type\": \"object\",\n          \"additionalProperties\": {\n            \"$ref\": \"#/definitions/Value\"\n          }\n        }\n      ]\n    },\n    \"Number\": {\n      \"description\": \"A valid ACL number.\",\n      \"anyOf\": [\n        {\n          \"description\": \"Represents an [`i64`].\",\n          \"type\": \"integer\",\n          \"format\": \"int64\"\n        },\n        {\n          \"description\": \"Represents a [`f64`].\",\n          \"type\": \"number\",\n          \"format\": \"double\"\n        }\n      ]\n    },\n    \"Target\": {\n      \"description\": \"Platform target.\",\n      \"oneOf\": [\n        {\n          \"description\": \"MacOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"macOS\"\n          ]\n        },\n        {\n          \"description\": \"Windows.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"windows\"\n          ]\n        },\n        {\n          \"description\": \"Linux.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"linux\"\n          ]\n        },\n        {\n          \"description\": \"Android.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"android\"\n          ]\n        },\n        {\n          \"description\": \"iOS.\",\n          \"type\": \"string\",\n          \"enum\": [\n            \"iOS\"\n          ]\n        }\n      ]\n    }\n  }\n}"
  },
  {
    "path": "desktop-pet/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<adaptive-icon xmlns:android=\"http://schemas.android.com/apk/res/android\">\n  <foreground android:drawable=\"@mipmap/ic_launcher_foreground\"/>\n  <background android:drawable=\"@color/ic_launcher_background\"/>\n</adaptive-icon>"
  },
  {
    "path": "desktop-pet/src-tauri/icons/android/values/ic_launcher_background.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<resources>\n  <color name=\"ic_launcher_background\">#fff</color>\n</resources>"
  },
  {
    "path": "desktop-pet/src-tauri/src/lib.rs",
    "content": "use base64::engine::general_purpose::STANDARD as B64;\nuse base64::Engine;\nuse serde::{Deserialize, Serialize};\nuse std::collections::HashMap;\nuse std::fs;\nuse std::io::{Read, Write};\nuse std::path::PathBuf;\nuse std::process::{Child, Command, Stdio};\nuse std::sync::Mutex;\nuse std::time::{Duration, Instant};\nuse tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder};\n\n// ── state.json ──\n\n#[derive(Debug, Serialize, Deserialize, Clone)]\npub struct PetState {\n    pub state: String,\n    pub detail: Option<String>,\n    pub progress: Option<f64>,\n    pub updated_at: Option<String>,\n}\n\n// ── layers.json input ──\n\n#[derive(Debug, Deserialize)]\nstruct CfgFile {\n    width: Option<u32>,\n    height: Option<u32>,\n    character: Option<CharCfg>,\n    layers: Option<Vec<LayerCfg>>,\n    sprites: Option<SpritesCfg>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct CharCfg {\n    x: Option<f64>,\n    y: Option<f64>,\n    scale: Option<f64>,\n    depth: Option<i32>,\n    wander: Option<f64>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LayerCfg {\n    image: String,\n    x: Option<f64>,\n    y: Option<f64>,\n    depth: Option<i32>,\n    scale: Option<f64>,\n    alpha: Option<f64>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct SpritesCfg {\n    frame_width: Option<u32>,\n    frame_height: Option<u32>,\n    anims: Option<HashMap<String, AnimCfg>>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct AnimCfg {\n    file: String,\n    frames: Option<u32>,\n    rate: Option<u32>,\n    #[serde(default = \"neg_one\")]\n    repeat: i32,\n}\n\nfn neg_one() -> i32 {\n    -1\n}\n\n// ── map.json input ──\n\n#[derive(Debug, Deserialize)]\nstruct MapCfgFile {\n    tile_size: Option<u32>,\n    cols: Option<u32>,\n    rows: Option<u32>,\n    zoom: Option<u32>,\n    tileset: String,\n    character_speed: Option<f64>,\n    ground: Vec<Vec<i32>>,\n    border: Option<Vec<Vec<i32>>>,\n    rug: Option<Vec<Vec<i32>>>,\n    objects: Vec<Vec<i32>>,\n    collision: Vec<Vec<u8>>,\n    pois: Option<HashMap<String, PoiCfg>>,\n    state_icons: Option<HashMap<String, String>>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct PoiCfg {\n    col: u32,\n    row: u32,\n}\n\n// ── IPC responses ──\n\n#[derive(Debug, Serialize)]\nstruct FullData {\n    width: u32,\n    height: u32,\n    character: CharData,\n    layers: Vec<LayerItem>,\n    sprites: Option<SpritesData>,\n}\n\n#[derive(Debug, Serialize)]\nstruct CharData {\n    x: f64,\n    y: f64,\n    scale: f64,\n    depth: i32,\n    wander: f64,\n}\n\n#[derive(Debug, Serialize)]\nstruct LayerItem {\n    data_url: String,\n    x: f64,\n    y: f64,\n    depth: i32,\n    scale: f64,\n    alpha: f64,\n}\n\n#[derive(Debug, Serialize)]\nstruct SpritesData {\n    frame_width: u32,\n    frame_height: u32,\n    anims: Vec<AnimItem>,\n}\n\n#[derive(Debug, Serialize)]\nstruct AnimItem {\n    key: String,\n    data_url: String,\n    frames: u32,\n    rate: u32,\n    repeat: i32,\n}\n\n#[derive(Debug, Serialize)]\nstruct MapData {\n    tile_size: u32,\n    cols: u32,\n    rows: u32,\n    zoom: u32,\n    tileset_url: String,\n    tileset_cols: u32,\n    character_speed: f64,\n    ground: Vec<Vec<i32>>,\n    border: Vec<Vec<i32>>,\n    rug: Vec<Vec<i32>>,\n    objects: Vec<Vec<i32>>,\n    collision: Vec<Vec<u8>>,\n    pois: HashMap<String, PoiOut>,\n    state_icons: HashMap<String, String>,\n}\n\n#[derive(Debug, Serialize)]\nstruct PoiOut {\n    col: u32,\n    row: u32,\n}\n\n// ── shared ──\n\nstruct AppPaths {\n    state_path: PathBuf,\n    layers_dir: PathBuf,\n}\n\nstruct BackendProcess {\n    child: Option<Child>,\n}\n\nimpl Drop for BackendProcess {\n    fn drop(&mut self) {\n        if let Some(child) = &mut self.child {\n            let _ = child.kill();\n            let _ = child.wait();\n        }\n    }\n}\n\nfn encode_image(path: &PathBuf) -> Result<String, String> {\n    let bytes = fs::read(path).map_err(|e| format!(\"{}: {e}\", path.display()))?;\n    let ext = path\n        .extension()\n        .and_then(|e| e.to_str())\n        .unwrap_or(\"png\");\n    let mime = match ext {\n        \"png\" => \"image/png\",\n        \"jpg\" | \"jpeg\" => \"image/jpeg\",\n        \"gif\" => \"image/gif\",\n        \"webp\" => \"image/webp\",\n        _ => \"image/png\",\n    };\n    Ok(format!(\"data:{mime};base64,{}\", B64.encode(&bytes)))\n}\n\n// ── commands ──\n\nfn read_state_file(state_path: &PathBuf) -> Result<PetState, String> {\n    let raw = fs::read_to_string(state_path)\n        .map_err(|e| format!(\"{}: {e}\", state_path.display()))?;\n    serde_json::from_str(&raw).map_err(|e| format!(\"parse: {e}\"))\n}\n\nfn read_state_via_backend() -> Result<PetState, String> {\n    let mut stream = std::net::TcpStream::connect(\"127.0.0.1:19000\")\n        .map_err(|e| format!(\"backend connect: {e}\"))?;\n    let _ = stream.set_read_timeout(Some(Duration::from_millis(1200)));\n    let _ = stream.set_write_timeout(Some(Duration::from_millis(1200)));\n\n    let request = b\"GET /status HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\nConnection: close\\r\\n\\r\\n\";\n    stream\n        .write_all(request)\n        .map_err(|e| format!(\"backend write: {e}\"))?;\n\n    let mut raw = String::new();\n    stream\n        .read_to_string(&mut raw)\n        .map_err(|e| format!(\"backend read: {e}\"))?;\n\n    let body = raw\n        .split_once(\"\\r\\n\\r\\n\")\n        .map(|(_, b)| b)\n        .ok_or_else(|| \"backend response parse failed\".to_string())?;\n    serde_json::from_str(body).map_err(|e| format!(\"backend json parse: {e}\"))\n}\n\nfn read_state_with_fallback(state_path: &PathBuf) -> Result<PetState, String> {\n    match read_state_file(state_path) {\n        Ok(state) => Ok(state),\n        Err(file_err) => {\n            eprintln!(\"⚠️ read state file failed, fallback to backend: {file_err}\");\n            read_state_via_backend()\n        }\n    }\n}\n\n#[tauri::command]\nfn read_state(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<PetState, String> {\n    let p = paths.lock().map_err(|e| e.to_string())?;\n    read_state_with_fallback(&p.state_path)\n}\n\n#[tauri::command]\nfn load_layers(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<FullData, String> {\n    let p = paths.lock().map_err(|e| e.to_string())?;\n    let cfg_path = p.layers_dir.join(\"layers.json\");\n\n    let cfg: CfgFile = if cfg_path.exists() {\n        let raw = fs::read_to_string(&cfg_path).map_err(|e| format!(\"layers.json: {e}\"))?;\n        serde_json::from_str(&raw).map_err(|e| format!(\"layers.json: {e}\"))?\n    } else {\n        CfgFile {\n            width: None,\n            height: None,\n            character: None,\n            layers: None,\n            sprites: None,\n        }\n    };\n\n    let w = cfg.width.unwrap_or(200);\n    let h = cfg.height.unwrap_or(250);\n    let cc = cfg.character.unwrap_or(CharCfg {\n        x: None, y: None, scale: None, depth: None, wander: None,\n    });\n    let character = CharData {\n        x: cc.x.unwrap_or(w as f64 / 2.0),\n        y: cc.y.unwrap_or(h as f64 * 0.66),\n        scale: cc.scale.unwrap_or(2.5),\n        depth: cc.depth.unwrap_or(0),\n        wander: cc.wander.unwrap_or(18.0),\n    };\n\n    let mut items = Vec::new();\n    for entry in cfg.layers.unwrap_or_default() {\n        let img_path = p.layers_dir.join(&entry.image);\n        if !img_path.exists() {\n            continue;\n        }\n        items.push(LayerItem {\n            data_url: encode_image(&img_path)?,\n            x: entry.x.unwrap_or(w as f64 / 2.0),\n            y: entry.y.unwrap_or(h as f64 / 2.0),\n            depth: entry.depth.unwrap_or(-1),\n            scale: entry.scale.unwrap_or(1.0),\n            alpha: entry.alpha.unwrap_or(1.0),\n        });\n    }\n\n    let sprites_data = if let Some(scfg) = cfg.sprites {\n        let fw = scfg.frame_width.unwrap_or(32);\n        let fh = scfg.frame_height.unwrap_or(32);\n        let mut anims = Vec::new();\n        for (key, acfg) in scfg.anims.unwrap_or_default() {\n            let img_path = p.layers_dir.join(&acfg.file);\n            if !img_path.exists() {\n                continue;\n            }\n            anims.push(AnimItem {\n                key,\n                data_url: encode_image(&img_path)?,\n                frames: acfg.frames.unwrap_or(1),\n                rate: acfg.rate.unwrap_or(4),\n                repeat: acfg.repeat,\n            });\n        }\n        Some(SpritesData {\n            frame_width: fw,\n            frame_height: fh,\n            anims,\n        })\n    } else {\n        None\n    };\n\n    Ok(FullData {\n        width: w,\n        height: h,\n        character,\n        layers: items,\n        sprites: sprites_data,\n    })\n}\n\n#[tauri::command]\nfn load_map(paths: tauri::State<'_, Mutex<AppPaths>>) -> Result<MapData, String> {\n    let p = paths.lock().map_err(|e| e.to_string())?;\n    let map_path = p.layers_dir.join(\"map.json\");\n\n    if !map_path.exists() {\n        return Err(\"map.json not found\".into());\n    }\n\n    let raw = fs::read_to_string(&map_path).map_err(|e| format!(\"map.json: {e}\"))?;\n    let cfg: MapCfgFile = serde_json::from_str(&raw).map_err(|e| format!(\"map.json: {e}\"))?;\n\n    let ts = cfg.tile_size.unwrap_or(16);\n    let cols = cfg.cols.unwrap_or(cfg.ground.first().map_or(12, |r| r.len() as u32));\n    let rows = cfg.rows.unwrap_or(cfg.ground.len() as u32);\n\n    let tileset_path = p.layers_dir.join(&cfg.tileset);\n    if !tileset_path.exists() {\n        return Err(format!(\"tileset not found: {}\", cfg.tileset));\n    }\n    let tileset_url = encode_image(&tileset_path)?;\n\n    // figure out tileset column count from image width\n    let img_bytes = fs::read(&tileset_path).map_err(|e| e.to_string())?;\n    let tileset_cols = png_width(&img_bytes).unwrap_or(160) / ts;\n\n    let mut pois = HashMap::new();\n    for (k, v) in cfg.pois.unwrap_or_default() {\n        pois.insert(k, PoiOut { col: v.col, row: v.row });\n    }\n\n    let icons_dir = p.layers_dir.join(\"Small (24x24) PNG\");\n    let mut state_icons = HashMap::new();\n    for (state, filename) in cfg.state_icons.unwrap_or_default() {\n        let path = icons_dir.join(&filename);\n        if path.exists() {\n            if let Ok(url) = encode_image(&path) {\n                state_icons.insert(state, url);\n            }\n        }\n    }\n\n    Ok(MapData {\n        tile_size: ts,\n        cols,\n        rows,\n        zoom: cfg.zoom.unwrap_or(2),\n        tileset_url,\n        tileset_cols,\n        character_speed: cfg.character_speed.unwrap_or(2.5),\n        ground: cfg.ground,\n        border: cfg.border.unwrap_or_default(),\n        rug: cfg.rug.unwrap_or_default(),\n        objects: cfg.objects,\n        collision: cfg.collision,\n        pois,\n        state_icons,\n    })\n}\n\nfn png_width(data: &[u8]) -> Option<u32> {\n    if data.len() < 24 || &data[0..4] != b\"\\x89PNG\" {\n        return None;\n    }\n    Some(u32::from_be_bytes([data[16], data[17], data[18], data[19]]))\n}\n\n// ── bootstrap ──\n\nfn find_project_root() -> PathBuf {\n    if let Ok(p) = std::env::var(\"STAR_PROJECT_ROOT\") {\n        let candidate = PathBuf::from(&p);\n        let abs = if candidate.is_absolute() {\n            candidate\n        } else {\n            std::env::current_dir().unwrap_or_default().join(candidate)\n        };\n        if abs.join(\"backend\").join(\"app.py\").exists() {\n            return abs;\n        }\n    }\n    let mut dir = std::env::current_dir().unwrap_or_default();\n    for _ in 0..8 {\n        if dir.join(\"backend\").join(\"app.py\").exists()\n            || dir.join(\"state.json\").exists()\n            || dir.join(\"state.sample.json\").exists()\n        {\n            return dir;\n        }\n        if !dir.pop() {\n            break;\n        }\n    }\n    if let Ok(home) = std::env::var(\"HOME\") {\n        let home = PathBuf::from(home);\n        let candidates = [\n            home.join(\"Documents\").join(\"GitHub\").join(\"Star-Office-UI\"),\n            home.join(\"GitHub\").join(\"Star-Office-UI\"),\n            home.join(\"Documents\").join(\"Star-Office-UI\"),\n            home.join(\"Star-Office-UI\"),\n        ];\n        for candidate in candidates {\n            if candidate.join(\"backend\").join(\"app.py\").exists() {\n                return candidate;\n            }\n        }\n    }\n    std::env::current_dir().unwrap_or_default()\n}\n\nfn spawn_backend(root: &PathBuf) -> Option<Child> {\n    if std::net::TcpStream::connect(\"127.0.0.1:19000\").is_ok() {\n        eprintln!(\"ℹ️ backend already running on 127.0.0.1:19000\");\n        return None;\n    }\n\n    let script = root.join(\"backend\").join(\"app.py\");\n    if !script.exists() {\n        eprintln!(\"⚠️ backend/app.py not found: {}\", script.display());\n        return None;\n    }\n\n    let mut candidates: Vec<(PathBuf, Vec<String>)> = vec![\n        (\n            root.join(\".venv\").join(\"bin\").join(\"python\"),\n            vec![script.to_string_lossy().to_string()],\n        ),\n        (\n            PathBuf::from(\"python3\"),\n            vec![script.to_string_lossy().to_string()],\n        ),\n        (\n            PathBuf::from(\"python\"),\n            vec![script.to_string_lossy().to_string()],\n        ),\n    ];\n\n    if let Ok(custom_python) = std::env::var(\"STAR_BACKEND_PYTHON\") {\n        candidates.insert(\n            0,\n            (\n                PathBuf::from(custom_python),\n                vec![script.to_string_lossy().to_string()],\n            ),\n        );\n    }\n\n    for (bin, args) in candidates {\n        let mut cmd = Command::new(&bin);\n        cmd.current_dir(root)\n            .args(&args)\n            .stdout(Stdio::inherit())\n            .stderr(Stdio::inherit());\n\n        match cmd.spawn() {\n            Ok(child) => {\n                eprintln!(\"🚀 backend started with {}\", bin.display());\n                return Some(child);\n            }\n            Err(err) => {\n                eprintln!(\"⚠️ failed to spawn {}: {}\", bin.display(), err);\n            }\n        }\n    }\n\n    None\n}\n\nfn wait_backend_ready() -> bool {\n    let deadline = Instant::now() + Duration::from_secs(20);\n    while Instant::now() < deadline {\n        if std::net::TcpStream::connect(\"127.0.0.1:19000\").is_ok() {\n            return true;\n        }\n        std::thread::sleep(Duration::from_millis(200));\n    }\n    false\n}\n\n#[tauri::command]\nfn enter_minimize_mode(\n    app: tauri::AppHandle,\n    paths: tauri::State<'_, Mutex<AppPaths>>,\n) -> Result<(), String> {\n    let main = app\n        .get_webview_window(\"main\")\n        .ok_or_else(|| \"main window not found\".to_string())?;\n    let mini = app\n        .get_webview_window(\"mini\")\n        .ok_or_else(|| \"mini window not found\".to_string())?;\n\n    let state_path = {\n        let p = paths.lock().map_err(|e| e.to_string())?;\n        p.state_path.clone()\n    };\n    if let Ok(snapshot) = read_state_with_fallback(&state_path) {\n        // Sync mini immediately before showing it, avoiding stale one-shot transition.\n        let _ = mini.emit(\"mini-sync-state\", snapshot);\n    }\n\n    // Keep mini near the main window top-left for continuity.\n    if let Ok(main_pos) = main.outer_position() {\n        let _ = mini.set_position(main_pos);\n    }\n\n    let _ = main.hide();\n    let _ = mini.show();\n    let _ = mini.set_focus();\n    Ok(())\n}\n\n#[tauri::command]\nfn restore_main_window(app: tauri::AppHandle) -> Result<(), String> {\n    let main = app\n        .get_webview_window(\"main\")\n        .ok_or_else(|| \"main window not found\".to_string())?;\n    let mini = app\n        .get_webview_window(\"mini\")\n        .ok_or_else(|| \"mini window not found\".to_string())?;\n\n    let _ = mini.hide();\n    let _ = main.show();\n    let _ = main.set_focus();\n    Ok(())\n}\n\n#[tauri::command]\nfn close_app(app: tauri::AppHandle) {\n    app.exit(0);\n}\n\n#[tauri::command]\nfn open_external_url(url: String) -> Result<(), String> {\n    #[cfg(target_os = \"macos\")]\n    let mut cmd = {\n        let mut c = Command::new(\"open\");\n        c.arg(&url);\n        c\n    };\n\n    #[cfg(target_os = \"windows\")]\n    let mut cmd = {\n        let mut c = Command::new(\"cmd\");\n        c.args([\"/C\", \"start\", \"\", &url]);\n        c\n    };\n\n    #[cfg(all(unix, not(target_os = \"macos\")))]\n    let mut cmd = {\n        let mut c = Command::new(\"xdg-open\");\n        c.arg(&url);\n        c\n    };\n\n    cmd.spawn()\n        .map(|_| ())\n        .map_err(|e| format!(\"failed to open browser: {e}\"))\n}\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run() {\n    let root = find_project_root();\n    eprintln!(\"📦 State : {}\", root.join(\"state.json\").display());\n    eprintln!(\"🎨 Layers: {}\", root.join(\"layers\").display());\n    let backend_child = spawn_backend(&root);\n    let backend_ready = wait_backend_ready();\n    if !backend_ready {\n        eprintln!(\"⚠️ backend not ready within 10s\");\n    }\n\n    tauri::Builder::default()\n        .manage(Mutex::new(BackendProcess { child: backend_child }))\n        .manage(Mutex::new(AppPaths {\n            state_path: root.join(\"state.json\"),\n            layers_dir: root.join(\"layers\"),\n        }))\n        .setup(|app| {\n            // Hidden mini window: transparent square with only avatar + status.\n            let mini = WebviewWindowBuilder::new(\n                app,\n                \"mini\",\n                WebviewUrl::App(\"minimized.html\".into()),\n            )\n            .title(\"Star Mini\")\n            .inner_size(220.0, 240.0)\n            .min_inner_size(180.0, 200.0)\n            .resizable(false)\n            .decorations(false)\n            .transparent(true)\n            .always_on_top(true)\n            .shadow(false)\n            .visible(false)\n            .build()\n            .map_err(|e| e.to_string())?;\n            let _ = mini.hide();\n            Ok(())\n        })\n        .invoke_handler(tauri::generate_handler![\n            read_state,\n            load_layers,\n            load_map,\n            enter_minimize_mode,\n            restore_main_window,\n            close_app,\n            open_external_url\n        ])\n        .run(tauri::generate_context!())\n        .expect(\"error while running tauri application\");\n}\n"
  },
  {
    "path": "desktop-pet/src-tauri/src/main.rs",
    "content": "#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nfn main() {\n    star_desktop_pet_lib::run();\n}\n"
  },
  {
    "path": "desktop-pet/src-tauri/tauri.conf.json",
    "content": "{\n  \"productName\": \"Star Desktop Pet\",\n  \"version\": \"0.1.0\",\n  \"identifier\": \"com.star.desktop-pet\",\n  \"build\": { \"frontendDist\": \"../src\" },\n  \"app\": {\n    \"macOSPrivateApi\": true,\n    \"withGlobalTauri\": true,\n    \"windows\": [\n      {\n        \"label\": \"main\",\n        \"title\": \"Star Desktop Pet\",\n        \"x\": 80,\n        \"y\": 60,\n        \"width\": 700,\n        \"height\": 500,\n        \"url\": \"http://127.0.0.1:19000/?desktop=1\",\n        \"decorations\": false,\n        \"transparent\": true,\n        \"alwaysOnTop\": true,\n        \"resizable\": true,\n        \"shadow\": false\n      }\n    ]\n  },\n  \"bundle\": {\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ]\n  }\n}\n"
  },
  {
    "path": "dist/Star-Office-UI-release-20260302/RELEASE_NOTES.md",
    "content": "# Star-Office-UI Release Notes (2026-03-02)\n\n## Summary\nThis package is a cleaned release snapshot for handoff/update.\nIt excludes runtime files, logs, and local backup artifacts.\n\n## Included\n- backend/\n- frontend/\n- docs/\n- assets/room-reference.png\n- core scripts and docs (README.md, SKILL.md, LICENSE, set_state.py, etc.)\n- asset-defaults.json / asset-positions.json\n\n## Excluded on purpose\n- .git/\n- .venv/\n- __pycache__/\n- *.log / *.out / *.pid\n- *.bak (frontend image backups)\n- assets/bg-history/ (historical generated backgrounds)\n- runtime state files: state.json / agents-state.json / join-keys.json\n\n## Artifact\n- File: `dist/Star-Office-UI-release-20260302.tgz`\n- SHA256: `bf52147b7664adc3c457eadd3748f969b1ad5ee7e8d3059ce9c8da4c6030f6ae`\n\n## Pre-publish checklist\n1. Confirm whether `asset-defaults.json` and `asset-positions.json` should be shipped as current defaults.\n2. Confirm whether `assets/bg-history/` should remain local-only (currently excluded).\n3. On target machine, create fresh `state.json` and `join-keys.json` if needed.\n4. Start backend and validate:\n   - `/health`\n   - `/status`\n   - language switches (EN/JP/CN)\n   - loading overlay + sidebar layering\n   - asset drawer selection / upload panel behavior\n"
  },
  {
    "path": "docs/CHANGELOG_2026-03.md",
    "content": "# CHANGELOG — 2026-03\n\n## 2026-03-06\n\n- 默认端口从 `18791` 调整为 `19000`，避开 OpenClaw Browser Control 端口冲突\n- 同步更新 `office-agent-push.py`、`healthcheck.sh`、`scripts/smoke_test.py` 的默认地址\n- 同步更新 Tauri / Electron 桌面壳默认连接地址\n- 同步更新 README / SKILL / join-office 文档中的本地访问与 tunnel 示例\n\n# Changelog — 2026-03 Refresh\n\n## Highlights\n\n- Added robust asset editing workflow in drawer (select/deselect, highlight sync, default/override split)\n- Added EN/JP/CN language buttons with real-time UI + loading + bubble text switching\n- Added room loading overlay with emoji rotation and localized copy\n- Fixed layering/layout issues (drawer overlap, detail overflow, canvas border fit)\n- Completed multi-round state sprite replacement pipeline (Writing/Idle/Syncing/Error) with auto frame sync\n- Updated syncing behavior: non-sync shows frame 0, syncing starts from frame 1\n- Disabled error movement path (error anim stays in place)\n- Removed GIF legacy assets and stale references\n- Restored `assets/room-reference.png` for reference background restore\n- Added configurable asset drawer password via env (`ASSET_DRAWER_PASS`, default `1234`)\n- Improved startup performance:\n  - static assets use long cache headers\n  - local phaser vendor restored\n\n## Security / Config\n\n- Asset drawer default pass changed to `1234`\n- Recommend deployment override:\n  - `ASSET_DRAWER_PASS=<your-strong-pass>`\n- Rationale: prevent unauthorized layout/asset modifications from shared links\n\n## AI model recommendation for room generation\n\nFor best style-transfer quality (while preserving room structure), recommend:\n\n1. gemini nanobanana pro\n2. gemini nanobanana 2\n\nOther models may produce unstable structure consistency.\n"
  },
  {
    "path": "docs/FEATURES_NEW_2026-03-01.md",
    "content": "# Star Office UI — 新增功能说明（本阶段）\n\n## 1. 多龙虾访客系统\n- 支持多个远端 OpenClaw 同时加入同一办公室。\n- 访客支持独立头像、名字、状态、区域、气泡。\n- 支持动态上下线与实时刷新。\n\n## 2. Join Key 机制升级\n- 从“一次性 key”升级为“固定可复用 key”。\n- 默认 key：`ocj_starteam01` ~ `ocj_starteam08`。\n- 保留安全控制：每个 key 的并发上限 `maxConcurrent`（默认 3）。\n\n## 3. 并发控制（已修复竞态）\n- 修复并发 join 的竞态问题（race condition）。\n- 同 key 第 4 个并发 join 会被正确拒绝（HTTP 429）。\n\n## 4. 访客状态映射与区域渲染\n- `idle -> breakroom`\n- `writing/researching/executing/syncing -> writing`\n- `error -> error`\n- 访客气泡文案与状态同步，不再错位。\n\n## 5. 访客动画与资源优化\n- 访客由静态图升级为动画精灵（像素风）。\n- `guest_anim_1~6` 已提供 webp 版本，减少加载体积。\n\n## 6. 名字与气泡显示优化\n- 非 demo 访客名字与气泡位置上移，避免角色遮挡。\n- 气泡锚点改为基于名字定位，保障“气泡在名字上方”。\n\n## 7. 移动端展示\n- 页面可在手机端直接访问与展示。\n- 布局已进行基础移动端适配，满足演示场景。\n\n## 8. 远端推送脚本联调改进\n- 支持从状态文件读取并推送状态到 office。\n- 增加状态来源诊断日志（用于定位“为何一直 idle”）。\n- 修复 AGENT_NAME 环境变量覆盖时序问题。\n"
  },
  {
    "path": "docs/OPEN_SOURCE_RELEASE_CHECKLIST.md",
    "content": "# Star Office UI — 开源发布准备清单（仅准备，不上传）\n\n## 0. 当前目标\n- 本文档用于“发布前准备”，不执行实际上传。\n- 所有 push 行为需海辛最终明确批准。\n\n## 1. 隐私与安全审查结果（当前仓库）\n\n### 发现高风险文件（必须排除）\n- 运行日志：\n  - `cloudflared.out`\n  - `cloudflared-named.out`\n  - `cloudflared-quick.out`\n  - `healthcheck.log`\n  - `backend.log`\n  - `backend/backend.out`\n- 运行状态：\n  - `state.json`\n  - `agents-state.json`\n  - `backend/backend.pid`\n- 备份/历史文件：\n  - `index.html.backup.*`\n  - `index.html.original`\n  - `*.backup*` 目录与文件\n- 本地虚拟环境与缓存：\n  - `.venv/`\n  - `__pycache__/`\n\n### 发现潜在敏感内容\n- 代码内含绝对路径 `/root/...`（建议改为相对路径或环境变量）\n- 文档与脚本含私有域名 `office.example.com`（可保留为示例，但建议改成占位域名）\n\n## 2. 必改项（提交前）\n\n### A. .gitignore（需补齐）\n建议新增：\n```\n*.log\n*.out\n*.pid\nstate.json\nagents-state.json\njoin-keys.json\n*.backup*\n*.original\n__pycache__/\n.venv/\nvenv/\n```\n\n### B. README 版权声明（必须新增）\n新增“美术资产版权与使用限制”章节：\n- 代码按开源协议（如 MIT）\n- 美术素材归原作者/工作室所有\n- 素材仅供学习/演示，**禁止商用**\n\n### C. 发布目录瘦身\n- 清理运行日志、运行态文件、备份文件\n- 仅保留“可运行最小集 + 必要素材 + 文档”\n\n## 3. 准备中的发布包建议结构\n```\nstar-office-ui/\n  backend/\n    app.py\n    requirements.txt\n    run.sh\n  frontend/\n    index.html\n    game.js (若仍需要)\n    layout.js\n    assets/* (仅可公开素材)\n  office-agent-push.py\n  set_state.py\n  state.sample.json\n  README.md\n  LICENSE\n  SKILL.md\n  docs/\n```\n\n## 4. 发布前最终核对（给海辛确认）\n- [ ] 是否保留私有域名示例（`office.example.com`）\n- [ ] 哪些美术资源允许公开（逐项确认）\n- [ ] README 非商用声明是否满足你的预期措辞\n- [ ] 是否需要将“阿文龙虾联调脚本”单独放 examples 目录\n\n## 5. 当前状态\n- ✅ 文档准备完成（总结、功能说明、Skill v2、发布检查清单）\n- ⏳ 等待海辛确认“公开素材范围 + 声明文案 + 是否开始执行打包清理脚本”\n- ⛔ 尚未执行 GitHub 上传\n"
  },
  {
    "path": "docs/PROJECT_MAINTENANCE_SOP.md",
    "content": "# Star-Office-UI 项目维护 SOP（轻量版）\n\n> 目标：让 Star-Office-UI 在继续增长的同时，保持仓库干净、回复友好、节奏稳定、社区感明确。\n\n---\n\n## 1. 总原则\n\n### 1.1 关闭 issue / PR 时，一定留一句 closure reason\n\n无论是：\n- 已修复\n- 重复\n- 超出当前范围\n- 提问者自行取消\n- 已被其他 PR / issue 吸收\n\n都尽量留一句话说明原因。\n\n**最小模板：**\n- Fixed in `commit/PR #xxx`, thanks for the report!\n- Closing as duplicate of #xxx, thank you!\n- Out of current scope for now, but welcome a focused PR.\n- Canceled by requester / resolved in latest master.\n\n目标不是“正式”，而是让后来人一眼看懂为什么被关。\n\n---\n\n## 2. Issue 处理规则\n\n### 2.1 先判断 issue 类型\n\n收到 issue 后，先分到四类之一：\n\n#### A. Bug report\n特征：报错、页面打不开、功能异常、状态不对\n\n处理方式：\n1. 复现 / 判断是否已知问题\n2. 如果已修：回复 + 给 commit / PR 号\n3. 如果未修：标记为待处理，必要时自己修 / 等 PR\n4. close 时一定写清楚“修在哪了”\n\n**推荐回复模板：**\n> 感谢反馈！这个问题已在 `PR #xx` / `commit xxx` 中修复。请拉取最新 master 后再试一下，如果还有问题欢迎继续反馈。\n\n---\n\n#### B. Support / setup question\n特征：怎么部署、为什么 Unauthorized、如何自动同步等\n\n处理方式：\n1. 先回答问题\n2. 给最短路径（README / SKILL / 命令）\n3. 如果文档能优化，顺手记成后续动作\n4. close 时说明“问题已答复，如仍有问题欢迎 reopen”\n\n**推荐回复模板：**\n> 这个问题大概率和 xxx 有关。最新版已经做了相关修复 / 文档补充。你可以先试试最新 master；如果还有问题，欢迎重新打开 issue。\n\n---\n\n#### C. Feature request\n特征：希望支持某个新能力、新方向、新体验\n\n处理方式：\n1. 明确是否感兴趣\n2. 不要误关成“已修复”\n3. 如果暂不做，也要说清楚“当前不做，但欢迎 PR / 后续讨论”\n\n**推荐回复模板：**\n> 这是个很好的方向，我们对这个想法感兴趣。不过它还不是当前阶段的既定工作项。如果你愿意推进，欢迎提一个更聚焦的 PR，我们可以一起讨论实现方式。\n\n---\n\n#### D. Duplicate / canceled / absorbed\n特征：重复提问、提问者自己放弃、已被其他 issue 吸收\n\n处理方式：\n1. 链接到对应 issue / PR\n2. 简短说明关闭原因\n3. 保持礼貌\n\n---\n\n## 3. PR 处理规则\n\n### 3.1 Merge 前检查四件事\n\n#### 1) 这个 PR 是不是解决了真实问题？\n- 是 bug fix 还是只是作者个人偏好？\n- 是否对应某个 issue / 用户痛点？\n\n#### 2) 改动范围是否可控？\n- 小而聚焦 → 倾向合并\n- 大而混杂 → 要求拆分 / 暂缓\n\n#### 3) 是否引入额外维护负担？\n- 新依赖\n- 新配置\n- 新架构\n- 新文档成本\n\n#### 4) 是否需要同步 README / changelog / release notes？\n如果影响用户使用路径，必须同步文档。\n\n---\n\n### 3.2 PR 结果分三类\n\n#### A. 直接合并\n适合：\n- 小 bug fix\n- 文档修正\n- 明确提升 onboarding / 稳定性\n\n#### B. 关闭但感谢\n适合：\n- 已被 master 提前修复\n- 重复 PR\n- 方向不错但当前不合适\n\n**原则：不合并 ≠ 否定贡献者**\n\n#### C. 请求作者调整后再看\n适合：\n- 思路对，但改动太大\n- 混入不相关内容\n- 需要拆小\n\n---\n\n### 3.3 关闭 PR 时，尽量做到三件事\n\n1. **先感谢**\n2. **再说明原因**\n3. **如果 possible，指出未来更容易被接受的方向**\n\n**推荐模板：**\n> Thanks for the PR — this is a thoughtful direction. We’re not merging it right now because xxx. If you’d like, a smaller / more focused PR around yyy would be much easier for us to review and land.\n\n---\n\n## 4. Release / 大版本收口流程\n\n适用于：\n- 一轮 bug fix 完成\n- 一次文档重构完成\n- 一次功能包发布（如 v1.0）\n\n### 发布前 checklist\n- [ ] 关键功能本机验证一次（至少 health / status / agents / set_state）\n- [ ] smoke test 跑通\n- [ ] README / SKILL / relevant docs 已同步\n- [ ] CHANGELOG 已更新\n- [ ] 相关 issue 已回复 / 关闭\n- [ ] 如果有贡献者，考虑在 README / release note 致谢\n- [ ] 确认仓库 worktree 干净，没有误提交文件\n\n### Release note 结构建议\n1. 这次版本是什么\n2. 核心变化 3-5 条\n3. 对用户有什么实际影响\n4. 快速体验方式\n5. 感谢贡献者\n\n---\n\n## 5. README / 文档维护规则\n\n### 5.1 README 优先回答四个问题\n1. 这是什么？\n2. 适合谁？\n3. 最快怎么用？\n4. 如果我是 OpenClaw 用户，最短路径是什么？\n\n### 5.2 文档更新触发条件\n以下情况发生时，要同步 README / docs：\n- 默认端口改变\n- 默认安装方式改变\n- 核心依赖或路径改变\n- onboarding 流程改变\n- 修复了高频 issue（尤其是部署 / 401 / loading 这类）\n\n---\n\n## 6. 社区关系维护\n\n### 6.1 要主动做的三件事\n- 在 README 或 release note 感谢明显贡献者\n- 对早期贡献者保持尊重，即使 PR 没合并\n- 对误解 / 错判及时补充说明\n\n### 6.2 哪些 contributor 值得重点维护\n优先维护这些人：\n- 连续提多个高质量 PR 的\n- 会主动补文档 / onboarding 的\n- 不只是修自己问题，而是在帮项目补完整性的\n\n---\n\n## 7. 当前阶段最适合 Star-Office-UI 的维护策略\n\n### 适合优先接收\n- bug fix\n- onboarding 改进\n- 文档优化\n- 小而明确的稳定性修复\n- 与 OpenClaw / agent 体验强相关的增强\n\n### 暂时谨慎对待\n- 大规模重构\n- 引入重依赖\n- 强绑定某个个人工作流的改动\n- 边界不清的大 feature\n\n---\n\n## 8. Star 自己要记住的维护原则\n\n- 不要为了“显得热情”而模糊关闭原因\n- 不要把 feature request 当成 bug fix 关掉\n- 不要 merge 之后忘了补文档\n- 不要忽略早期贡献者\n- 仓库看起来干净，本身就是产品体验的一部分\n\n---\n\n## 一句话版本\n\n> **小问题及时收口，大问题说清边界；每次关闭都留痕，每次发布都成阶段。**\n"
  },
  {
    "path": "docs/PROJECT_SUMMARY_2026-03-01.md",
    "content": "# Star Office UI — 项目阶段总结（2026-03-01）\n\n## 一、今日工作总结\n\n今天主要完成了两条主线：\n\n1. **多龙虾（多 OpenClaw）加入办公室能力稳定化**\n2. **手机版展示能力完善**\n\n并且围绕“阿文龙虾状态同步不稳定”做了多轮排查，明确了链路问题与当前未完全闭环点。\n\n---\n\n## 二、已完成能力（可对外描述）\n\n### 1) 多 Agent 加入与显示\n- 支持多个远端 OpenClaw 通过 `join-agent` 加入办公室。\n- 每个访客有独立 `agentId`、名字、状态、区域与动画。\n- 场景会基于 `/agents` 动态创建、更新、移除访客。\n\n### 2) 固定可复用 Join Key 机制\n- 一次性 key 改为固定可复用 key：`ocj_starteam01` ~ `ocj_starteam08`。\n- 去掉了“used 即不可再用”的阻断逻辑，支持长期复用。\n- 加入了并发上限配置（`maxConcurrent`），默认每个 key 限 3 并发在线。\n\n### 3) 并发限制修复（关键）\n- 发现 4 并发仍能通过的根因是后端竞争条件（race condition）。\n- 在 `join-agent` 临界区增加锁 + 锁内重读状态，修复后压测通过：\n  - 前 3 个 200\n  - 第 4 个 429\n\n### 4) 访客动画与性能优化\n- 访客动画改为像素动画精灵，不再是静态星星。\n- `guest_anim_1~6` 已转为 `.webp`，显著降低加载体积。\n- 前端预加载与渲染资源已切换到 webp 优先。\n\n### 5) 状态 → 区域映射统一\n- 规则统一：\n  - `idle -> breakroom`\n  - `writing/researching/executing/syncing -> writing`\n  - `error -> error`\n- 访客 bubble 文案已按状态做映射，不再与区域脱节。\n\n### 6) 名字与气泡层级/位置优化\n- 非 demo 访客名字、气泡位置上移，减少遮挡。\n- 访客气泡锚点改为相对名字计算，确保“气泡在名字上方”。\n- demo 与真实访客路径已区分，互不干扰。\n\n### 7) 手机版展示\n- 现有 UI 在手机端可访问与展示，适合演示与外部查看。\n- 关键控件布局做过整理，移动端基本可用。\n\n---\n\n## 三、当前未完全闭环点（诚实披露）\n\n### 阿文龙虾“真实状态稳定同步”仍存在偶发不一致\n虽然链路已多次验证打通（writing 能进工作区、idle 能回休息区），但线上实测仍出现过：\n- 本地脚本持续推 idle（旧版本脚本 / 读错状态源）\n- 403 未授权（离线状态恢复/旧 agentId 缓存问题）\n- 前台退出触发 leave-agent 后角色消失\n\n> 结论：\n> - “机制可行、链路可通”已经验证；\n> - “端到端持续稳定”还需要继续收口（尤其阿文侧运行脚本版本统一、状态源统一、常驻策略统一）。\n\n---\n\n## 四、今天新增/调整文件（核心）\n\n- `backend/app.py`\n  - join 并发限制加锁修复\n  - offline/approved 授权流逻辑调整（便于恢复）\n- `join-keys.json`\n  - 固定 key + `maxConcurrent: 3`\n- `frontend/index.html`（及相关渲染逻辑）\n  - 访客动画、名字与气泡定位优化\n  - 状态文案映射调整\n- `office-agent-push.py`（多版本并行调试）\n  - 增加状态源诊断日志\n  - 增加环境变量覆盖逻辑\n  - 修复 AGENT_NAME 读取时机问题\n\n---\n\n## 五、对外开源前建议描述（建议文案）\n\n> Star Office UI 是一个可视化多 Agent 像素办公室：\n> 支持多个 OpenClaw 远端接入、状态驱动位置渲染、访客动画与移动端访问。\n> 项目当前已完成多 Agent 主链路与 UI 能力；状态同步稳定性仍在持续优化中。\n\n---\n\n## 六、下一步（建议）\n1. 统一阿文侧运行脚本“唯一来源”，避免旧版本混跑。\n2. 增加 `/agent-push` 与前端渲染诊断日志（可开关）。\n3. 增加“状态过期自动 idle”兜底（脚本侧 + 服务端侧双保险）。\n4. 补一份可复现联调流程（10 分钟 smoke test）。\n5. 完成开源前隐私清理与发布清单（见 `docs/OPEN_SOURCE_RELEASE_CHECKLIST.md`）。\n"
  },
  {
    "path": "docs/PR_DRAFT_2026-03-refresh.md",
    "content": "# PR Draft — Star Office UI March Refresh\n\n## Title\nfeat: asset editor + i18n + loading UX + sprite pipeline + security/perf refinements\n\n## Summary\nThis PR delivers a full refresh of Star Office UI across UX, asset pipeline, localization, stability, and deployment security.\n\n### What changed\n\n#### 1) Asset editor / room decoration\n- Improved drawer selection UX (select/deselect + dual highlight sync)\n- Upload panel now appears only when an asset is selected\n- Added defaults/overrides split:\n  - `GET/POST /assets/defaults`\n  - `GET/POST /assets/positions`\n- Added “Set Default” flow for persistent base placement\n\n#### 2) Localization (CN/EN/JP)\n- Replaced language toggles with EN / JP / CN buttons\n- Active language button highlighted in green\n- Real-time language switching for:\n  - UI labels\n  - loading texts\n  - role/cat/guest bubbles\n  - initial boot loading sentence\n\n#### 3) Loading overlay and UX polish\n- Added room-bound loading overlay with emoji rotation\n- Updated copy to voyage-themed localized sets\n- Trigger timing fixed: overlay shows immediately on click\n- Overlay and detail placement bound to canvas rect for consistency\n\n#### 4) Layout and layering fixes\n- Fixed canvas border fit and theme color unification (#64477d)\n- Ensured status/detail stays inside canvas and single-line clipped\n- Drawer open now shifts main stage to avoid overlap and large gaps\n- Drawer kept above room loading overlay\n\n#### 5) Sprite replacement pipeline hardening\n- Reworked replacement flow to detect frame size/count from incoming animated webp\n- Synced loader + animation frame ranges to avoid flicker\n- Applied across Writing / Idle / Syncing / Error replacements\n- Syncing behavior adjusted:\n  - non-sync state shows frame 0\n  - syncing animation starts from frame 1\n- Error animation movement path removed (fixed in place)\n\n#### 6) Cleanup / reliability / perf\n- Removed legacy GIF assets\n- Removed stale asset references (zero missing static refs)\n- Restored `assets/room-reference.png` for restore-reference endpoint\n- Added configurable drawer pass via env:\n  - `ASSET_DRAWER_PASS` (default `1234`)\n- Performance improvements:\n  - static assets served with long cache headers\n  - local phaser vendor restored to reduce cold-load latency\n\n## Documentation updates included\n- README rewritten for latest behavior/config\n- SKILL updated with deployment + safety + replacement SOP\n- LICENSE updated to remove old third-party character disclaimer and keep:\n  - code MIT\n  - art assets non-commercial\n- Added `docs/CHANGELOG_2026-03.md`\n\n## Deployment notes\n- Recommended model for room generation:\n  1. gemini nanobanana pro\n  2. gemini nanobanana 2\n- Security recommendation:\n  - always override `ASSET_DRAWER_PASS` in production/public deployments\n\n## Test checklist\n- [ ] Open page cold + warm load\n- [ ] Switch CN/EN/JP at any state\n- [ ] Trigger Move Home/Broker and observe local status text + loading overlay\n- [ ] Replace one animated asset and verify frame sync/no flicker\n- [ ] Verify Error is fixed in place\n- [ ] Verify `/assets/restore-reference-background` works with `assets/room-reference.png`\n- [ ] Verify no missing `/static/*` refs in runtime logs\n\n## How to create PR\n1. `git checkout -b feat/march-refresh`\n2. `git push -u origin feat/march-refresh`\n3. Open PR to `ringhyacinth/Star-Office-UI:main`\n4. Paste this document as PR description\n"
  },
  {
    "path": "docs/PR_FILELIST_2026-03-refresh.md",
    "content": "# PR File List — 2026-03 Refresh\n\n## Core code changes\n- `frontend/index.html`\n- `backend/app.py`\n- `frontend/vendor/phaser-3.80.1.min.js`\n- `assets/room-reference.webp`\n\n## Configuration / templates\n- `.gitignore`\n- `runtime-config.sample.json`\n\n## Documentation\n- `README.md`\n- `SKILL.md`\n- `LICENSE`\n- `docs/CHANGELOG_2026-03.md`\n- `docs/PR_DRAFT_2026-03-refresh.md`\n- `docs/PR_FILELIST_2026-03-refresh.md`\n\n## Notes (excluded from PR)\n- `state.json`, `agents-state.json`, `runtime-config.json` (local runtime)\n- `assets/bg-history/` (local generated history)\n- `frontend/*.bak` (local backups)\n- temporary dist packages\n"
  },
  {
    "path": "docs/STAR_OFFICE_UI_OVERVIEW.md",
    "content": "# Star Office UI — 功能说明（Overview）\n\nStar Office UI 是一个“像素办公室”可视化界面，用来把 AI 助手/多个 OpenClaw 访客的状态，渲染成可在网页（含手机）查看的小办公室场景。\n\n## 你能看到什么\n- 像素办公室背景（俯视图）\n- 角色（Star + 访客）会根据状态在不同区域移动\n- 名字与气泡（bubble）展示当前状态/想法（可自定义映射）\n- 手机端打开也能展示（适合作品展示/直播/对外演示）\n\n## 核心能力\n\n### 1) 单 Agent（本地 Star）状态渲染\n- 后端读取 `state.json` 提供 `GET /status`\n- 前端轮询 `/status`，根据 `state` 渲染 Star 所在区域\n- 提供 `set_state.py` 快速切换状态\n\n### 2) 多访客（多龙虾）加入办公室\n- 访客通过 `POST /join-agent` 加入，获得 `agentId`\n- 访客通过 `POST /agent-push` 持续推送自己的状态\n- 前端通过 `GET /agents` 拉取访客列表并渲染\n\n### 3) Join Key（接入密钥）机制\n- 支持固定可复用 join key（如 `ocj_starteam01~08`）\n- 支持每个 key 的并发在线上限（默认 3）\n- 便于控制“谁能进办公室”和“同一个 key 同时可进几只龙虾”\n\n### 4) 状态 → 区域映射（统一逻辑）\n- idle → breakroom（休息区）\n- writing / researching / executing / syncing → writing（工作区）\n- error → error（故障区）\n\n### 5) 访客动画与性能优化\n- 访客角色使用动画精灵\n- 支持 WebP 资源（体积更小、加载更快）\n\n### 6) 名字/气泡不遮挡的布局\n- 真实访客与 demo 访客分离逻辑\n- 非 demo 访客名字与气泡整体上移\n- bubble 锚定在名字上方，避免压住名字\n\n### 7) Demo 模式（可选）\n- `?demo=1` 才显示 demo 访客（默认不显示）\n- demo 与真实访客互不影响\n\n## 主要接口（Backend）\n- `GET /`：前端页面\n- `GET /status`：单 agent 状态（兼容旧版）\n- `GET /agents`：多 agent 列表（访客渲染用）\n- `POST /join-agent`：访客加入\n- `POST /agent-push`：访客推送状态\n- `POST /leave-agent`：访客离开\n- `GET /health`：健康检查\n\n## 安全与隐私注意\n- 不要把隐私信息写进 `detail`（因为会被渲染/可被拉取）\n- 开源前必须清理：日志、运行态文件、join keys、隧道输出等\n\n## 美术资产使用声明（必须）\n- 代码可开源，但美术素材（背景、角色、动画等）版权归原作者/工作室所有。\n- 美术资产仅供学习与演示，**禁止商用**。\n"
  },
  {
    "path": "docs/UPDATE_REPORT_2026-03-04_P0_P1.md",
    "content": "# Star Office UI 更新文档（P0 / P1）\n\n更新时间：2026-03-04\n分支：`feat/office-art-rebuild`\n\n---\n\n## 1. 更新目标\n\n本轮更新目标分为两层：\n\n- **P0：安全与可发布性**（防泄漏、防弱配置、上线前可自检）\n- **P1：结构与稳定性优化**（不减功能、提升状态同步与加载体验）\n\n同时处理了线上关键问题：\n\n- 服务偶发 502（进程/服务启动方式不稳定）\n- 角色状态与真实工作状态不一致（尤其“回复结束仍在工位”）\n\n---\n\n## 2. P0 已完成项\n\n### 2.1 后端安全基线加固\n\n- 增加生产模式安全校验（弱密钥/弱口令阻止启动）\n- Session Cookie 安全参数加固（HttpOnly / SameSite / Secure）\n- `runtime-config.json` 写入后自动尝试收紧文件权限（`600`）\n\n### 2.2 敏感文件治理\n\n- `.gitignore` 补充运行态文件与高风险文件\n- 引入样例文件替代运行态文件：\n  - `join-keys.sample.json`\n  - `.env.example`\n- `join-keys.json` 改为运行时初始化，不再作为仓库内固定配置\n\n### 2.3 上线前安全自检能力\n\n- 新增 `scripts/security_check.py`\n- 可检查：\n  - 弱 secret / 弱口令\n  - 风险文件是否被 git 跟踪\n  - 常见敏感 token 模式\n\n---\n\n## 3. P1 已完成项（不改业务能力）\n\n### 3.1 后端结构拆分\n\n在不改变现有 API 行为前提下，把 `backend/app.py` 拆出：\n\n- `backend/security_utils.py`\n- `backend/memo_utils.py`\n- `backend/store_utils.py`\n\n收益：\n- 降低单文件复杂度\n- 降低后续功能改动时的回归风险\n- 提升可读性与维护效率\n\n### 3.2 状态同步修复（核心）\n\n- 修复状态源路径优先级（避免读取错误状态文件）\n- 增加 stale 状态自动回 `idle` 机制（避免假工作中）\n- 前端状态轮询改为更快节奏并强制视觉对齐，避免动画卡旧状态\n\n### 3.3 生图模型策略收敛\n\n按需求收敛为两种用户模型语义：\n\n- `nanobanana-pro`\n- `nanobanana-2`\n\n并补充 provider 映射与错误细节透出，提升可诊断性。\n\n### 3.4 首屏性能与体感优化\n\n- 首页 HTML 缓存（后端进程内缓存）\n- 非关键初始化延后（先出画面）\n- 加入画布骨架屏，减少“黑屏 + 长时间加载中”体感\n- 加速 loading overlay 淡出\n\n---\n\n## 4. 线上稳定性修复（本轮重点）\n\n### 4.1 502 根因\n\nCloudflare 正常，但 `18888` 源站进程存在不稳定/启动方式不一致，导致偶发 connection refused。\n\n### 4.2 已处理\n\n- 修复并统一 `star-office-ui.service` 启动方式（systemd 常驻）\n- 清理手工临时启动造成的端口抢占\n- 重启并验证：\n  - `star-office-ui.service` 运行正常\n  - `star-office-push.service` 运行正常\n\n---\n\n## 5. 当前已知风险 / 待跟进\n\n1. **状态策略仍需完全事件化**\n   - 目前已大幅收敛误判，但建议后续做单一状态控制器（显式事件优先，彻底禁用隐式推断）\n\n2. **进程模型仍是 Flask 开发服务器**\n   - 当前可用但不理想，后续建议迁移为 gunicorn/uvicorn 等生产进程模型\n\n3. **动画状态同步仍建议增加端到端回归脚本**\n   - 尤其 writing / syncing / error / idle 切换链路\n\n---\n\n## 6. 验收建议（人工）\n\n验收地址：`https://simonoffice.hyacinth.im/`\n\n建议至少覆盖：\n\n1. 首页进入速度与骨架屏体验\n2. 状态切换（writing / syncing / error / idle）\n3. 回复结束后是否回到待命区\n4. 生图两入口（搬新家 / 找中介）\n5. 断网或服务短时波动后是否自动恢复\n\n---\n\n## 7. 提交范围（摘要）\n\n本轮主要覆盖：\n\n- 安全与配置：P0\n- 后端重构：P1\n- 状态同步与动画一致性修复\n- 生图模型策略与错误诊断\n- 加载性能与体验优化\n- systemd 常驻与稳定性修复\n\n如需 PR 附件，可直接将本文件作为“更新说明 / Release Notes”。\n"
  },
  {
    "path": "docs/UPDATE_REPORT_2026-03-05.md",
    "content": "# 更新报告 — 2026-03-05\n\n> 本次更新覆盖 8 个 commit，聚焦「稳定性修复 + 移动端体验 + 安全收尾」。\n\n---\n\n## 变更概览\n\n| # | Commit | 分类 | 说明 |\n|---|--------|------|------|\n| 1 | `878793d` | 🐛 fix | 修复 CDN 缓存 404 导致页面无法加载 |\n| 2 | `cc22403` | 🐛 fix | 修复 `fetchStatus()` 中多余的 `else` 块导致 JS 语法错误 |\n| 3 | `103f944` | 🐛 fix | 生图接口改为异步任务模式，避免 Cloudflare 524 超时 |\n| 4 | `ee141de` | 🧹 chore | 清理本地测试时意外提交的文件 |\n| 5 | `83e61ff` | 🧹 chore | 将 `join-keys.json` 加入 `.gitignore`（运行时数据不入库） |\n| 6 | `899f27e` | 🐛 fix | 移动端/iPad 侧边栏修复（遮罩层 + body 滚动锁定 + `100dvh`） |\n| 7 | `5aef430` | 🐛 fix | 移动端 drawer 关闭时完全移出屏幕（`right: -100vw`） |\n| 8 | `02a731e` | ✨ feat | 新增 join key 级别过期时间 + 并发上限支持 |\n\n---\n\n## 详细说明\n\n### 1. 修复 CDN 缓存 404（`878793d`）\n\n**问题**：`/static/` 路径下的所有响应（含 404）都被设置了一年长缓存头。Cloudflare 缓存了 `phaser.js` 的 404 响应长达 2.7 天，导致 `office.hyacinth.im` 完全无法加载。\n\n**修复**：\n- `add_no_cache_headers` 仅对 2xx 响应设置长缓存，非 2xx 响应设为 no-cache\n- 给 `phaser.js` 的 `<script>` 标签添加 `?v={{VERSION_TIMESTAMP}}` 缓存破坏参数\n\n### 2. 修复 fetchStatus JS 语法错误（`cc22403`）\n\n**问题**：`fetchStatus()` 函数内 `try/catch` 之间存在一个孤立的 `} else { ... }` 块，破坏了 JS 语法结构，导致浏览器报 `Missing catch or finally after try`，整个页面卡在 loading。\n\n**修复**：移除多余的 `else` 块（其中的打字机逻辑已被前面的 `if/else` 分支覆盖）。\n\n> ⚠️ 此 bug 是 GitHub 上 PR #49、#51、#52 同时在修的问题，三个 PR 现在可以关闭。\n\n### 3. 生图接口异步化（`103f944`）\n\n**问题**：`POST /assets/generate-rpg-background` 是同步的，生图通常需要 30~120 秒。Cloudflare 的代理超时限制为 100 秒（HTTP 524），导致公网用户频繁触发超时。\n\n**修复**：\n- 后端：拆分为 `_bg_generate_worker`（后台线程）+ `POST /assets/generate-rpg-background`（返回 `task_id`）+ `GET /assets/generate-rpg-background/poll`（轮询结果）\n- 前端：新增 `_startAndPollGeneration()` 函数，提交任务后每 3 秒轮询，显示实时等待进度\n- 同时抽取了 `_handleGenError()` 统一错误处理（DRY 优化）\n- 防重入：如果已有生图任务在跑，直接返回已有 `task_id`\n\n### 4-5. 清理与 gitignore（`ee141de` + `83e61ff`）\n\n- 清理了测试时意外提交的文件\n- 将 `join-keys.json` 加入 `.gitignore`（包含密钥数据，不应入库）\n\n### 6-7. 移动端侧边栏修复（`899f27e` + `5aef430`）\n\n**问题**：移动端/iPad 打开资产侧边栏时，背后的页面仍可滚动；关闭侧边栏后 drawer 只偏移 -320px，在宽屏移动设备上仍可见。\n\n**修复**：\n- 新增 `#asset-drawer-backdrop` 遮罩层，点击即关闭 drawer\n- 打开 drawer 时 `body` 加 `drawer-open` class（`overflow:hidden; position:fixed; touch-action:none`）\n- 关闭时恢复 `scrollY` 位置（避免跳顶部）\n- Drawer 关闭状态改为 `right: -100vw`（完全移出视口）\n- 使用 `100dvh` 适配移动端 dynamic viewport\n- 添加 `overscroll-behavior: contain` 防止 drawer 内滚动穿透\n\n### 8. Join Key 级别过期时间（`02a731e`）\n\n**新增功能**：\n- `join-keys.json` 中每个 key 支持 `expiresAt` 字段（ISO 8601 时间戳）\n- `join-agent` 和 `agent-push` 两个端点在执行前都会检查 key 是否过期\n- 过期后返回友好提示：\"该接入密钥已过期，活动已结束 🎉\"\n- 支持 `maxConcurrent` 字段控制同一个 key 的并发在线数\n\n---\n\n## 潜在风险评估\n\n| 风险点 | 等级 | 说明 |\n|--------|------|------|\n| 异步任务内存泄漏 | 🟡 低 | `_bg_tasks` 在任务完成并被 poll 消费后会清理；但如果前端从未 poll（如用户关闭页面），任务对象会残留。当前风险极低（生图频率低），后续可加定期清理。 |\n| `join-keys.json` 历史泄露 | 🟢 已解决 | 已加入 `.gitignore`，但如果之前有 commit 包含此文件，历史中仍存在。建议确认远端历史是否干净。 |\n| 前端 `fetchStatus` 修复 | 🟢 已验证 | 修复后的 `try/catch` 结构完整，本地运行正常。 |\n| 移动端 drawer `position:fixed` | 🟢 低 | iOS Safari 下 `position:fixed` + `100dvh` 的组合偶有兼容问题，但已是业界最佳实践。 |\n\n**结论：无新增 bug 风险，可以安全推送。**\n\n---\n\n## 文件变更统计\n\n```\n.gitignore                    |   1 +\nbackend/app.py                | 166 ++++++++++++++++++------\nfrontend/index.html           | 162 ++++++++++++++++--------\nfrontend/join-office-skill.md | 102 +++++++++------\nfrontend/office-agent-push.py | 286 ++++++++++++++++++++++++++++++++++++++++++\noffice-agent-push.py          |   2 +-\n共 6 个文件，+589 行，-130 行\n```\n"
  },
  {
    "path": "electron-shell/README.md",
    "content": "# Star Desktop Pet (Electron Shell)\n\n这个目录是 Electron 版桌面壳，和现有 Tauri 版并行存在，方便逐步迁移。\n\n## 已接入能力\n\n- 复用原有前端：`http://127.0.0.1:19000/?desktop=1`\n- 复用 mini 页面：`desktop-pet/src/minimized.html`\n- 启动时自动拉起 Python backend（若未运行）\n- 主窗口 / mini 窗口切换\n- 托盘（menu bar）常驻菜单\n- 通过 preload 注入 `window.__TAURI__` 兼容层，尽量少改现有前端逻辑\n\n## 启动方式\n\n```bash\ncd \"/Users/wangzhaohan/Documents/GitHub/Star-Office-UI/electron-shell\"\nnpm install\nnpm run dev\n```\n\n## 可选环境变量\n\n- `STAR_PROJECT_ROOT`：项目根目录（默认自动探测）\n- `STAR_BACKEND_PYTHON`：后端 Python 可执行路径\n- `STAR_BACKEND_HOST`：后端主机（默认 `127.0.0.1`）\n- `STAR_BACKEND_PORT`：后端端口（默认 `19000`）\n\n## 说明\n\n- 当前阶段是“可运行迁移骨架”，目的是先替换桌面容器层。\n- 现有 Tauri 目录不受影响，可随时回滚或并行对比。\n"
  },
  {
    "path": "electron-shell/main.js",
    "content": "const { app, BrowserWindow, Tray, Menu, ipcMain, shell, nativeImage } = require(\"electron\");\nconst { spawn } = require(\"child_process\");\nconst fs = require(\"fs\");\nconst path = require(\"path\");\nconst net = require(\"net\");\nconst APP_NAME = \"Star Office UI\";\nconst BACKEND_HOST = process.env.STAR_BACKEND_HOST || \"127.0.0.1\";\nconst rawBackendPort = Number(process.env.STAR_BACKEND_PORT || 19000);\nconst BACKEND_PORT = Number.isFinite(rawBackendPort) && rawBackendPort > 0 ? rawBackendPort : 19000;\nconst BACKEND_BASE_URL = `http://${BACKEND_HOST}:${BACKEND_PORT}`;\n\nlet mainWindow = null;\nlet miniWindow = null;\nlet assetWindow = null;\nlet tray = null;\nlet backendChild = null;\nlet isQuitting = false;\nlet currentUiLang = \"en\";\n\nfunction sleep(ms) {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction tcpReachable(host, port, timeoutMs = 500) {\n  return new Promise((resolve) => {\n    const socket = new net.Socket();\n    let settled = false;\n    const done = (ok) => {\n      if (settled) return;\n      settled = true;\n      socket.destroy();\n      resolve(ok);\n    };\n    socket.setTimeout(timeoutMs);\n    socket.once(\"connect\", () => done(true));\n    socket.once(\"timeout\", () => done(false));\n    socket.once(\"error\", () => done(false));\n    socket.connect(port, host);\n  });\n}\n\nasync function waitBackendReady(timeoutMs = 20000) {\n  const deadline = Date.now() + timeoutMs;\n  while (Date.now() < deadline) {\n    if (await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400)) return true;\n    await sleep(200);\n  }\n  return false;\n}\n\n\nfunction findProjectRoot() {\n  if (process.env.STAR_PROJECT_ROOT) {\n    const custom = path.isAbsolute(process.env.STAR_PROJECT_ROOT)\n      ? process.env.STAR_PROJECT_ROOT\n      : path.resolve(process.cwd(), process.env.STAR_PROJECT_ROOT);\n    if (fs.existsSync(path.join(custom, \"backend\", \"app.py\"))) return custom;\n  }\n\n  const fromDir = __dirname;\n  let cursor = fromDir;\n  for (let i = 0; i < 8; i += 1) {\n    if (fs.existsSync(path.join(cursor, \"backend\", \"app.py\"))) return cursor;\n    const parent = path.dirname(cursor);\n    if (parent === cursor) break;\n    cursor = parent;\n  }\n\n  const home = process.env.HOME || \"\";\n  const candidates = [\n    path.join(home, \"Documents\", \"GitHub\", \"Star-Office-UI\"),\n    path.join(home, \"GitHub\", \"Star-Office-UI\"),\n    path.join(home, \"Documents\", \"Star-Office-UI\"),\n    path.join(home, \"Star-Office-UI\"),\n  ];\n  for (const c of candidates) {\n    if (fs.existsSync(path.join(c, \"backend\", \"app.py\"))) return c;\n  }\n\n  return process.cwd();\n}\n\nfunction resolveAppIconPath(projectRoot) {\n  const candidates = [\n    path.join(projectRoot, \"desktop-pet\", \"src-tauri\", \"icons\", \"icon.png\"),\n    path.join(projectRoot, \"desktop-pet\", \"src-tauri\", \"icons\", \"128x128@2x.png\"),\n    path.join(projectRoot, \"desktop-pet\", \"src-tauri\", \"icons\", \"128x128.png\"),\n    path.join(projectRoot, \"desktop-pet\", \"src-tauri\", \"icons\", \"32x32.png\"),\n  ];\n  for (const p of candidates) {\n    if (fs.existsSync(p)) return p;\n  }\n  return null;\n}\n\nfunction applyAppIcon(projectRoot) {\n  const iconPath = resolveAppIconPath(projectRoot);\n  if (!iconPath) return null;\n  const iconImg = nativeImage.createFromPath(iconPath);\n  if (iconImg.isEmpty()) return null;\n\n  if (process.platform === \"darwin\" && app.dock && app.dock.setIcon) {\n    app.dock.setIcon(iconImg);\n  }\n  return iconPath;\n}\n\nfunction readStateFile(statePath) {\n  const raw = fs.readFileSync(statePath, \"utf-8\");\n  return JSON.parse(raw);\n}\n\nfunction readStateViaBackend() {\n  return new Promise((resolve, reject) => {\n    const req = `GET /status HTTP/1.1\\r\\nHost: ${BACKEND_HOST}\\r\\nConnection: close\\r\\n\\r\\n`;\n    const socket = net.createConnection({ host: BACKEND_HOST, port: BACKEND_PORT });\n    let buf = \"\";\n    socket.setTimeout(1200);\n    socket.on(\"connect\", () => socket.write(req));\n    socket.on(\"data\", (chunk) => {\n      buf += chunk.toString(\"utf-8\");\n    });\n    socket.on(\"timeout\", () => {\n      socket.destroy();\n      reject(new Error(\"backend timeout\"));\n    });\n    socket.on(\"error\", reject);\n    socket.on(\"end\", () => {\n      const sep = \"\\r\\n\\r\\n\";\n      const idx = buf.indexOf(sep);\n      if (idx === -1) {\n        reject(new Error(\"invalid backend response\"));\n        return;\n      }\n      try {\n        resolve(JSON.parse(buf.slice(idx + sep.length)));\n      } catch (e) {\n        reject(e);\n      }\n    });\n  });\n}\n\nasync function readStateWithFallback(projectRoot) {\n  const statePath = path.join(projectRoot, \"state.json\");\n  try {\n    return readStateFile(statePath);\n  } catch (_) {\n    return readStateViaBackend();\n  }\n}\n\nfunction spawnBackend(projectRoot) {\n  const script = path.join(projectRoot, \"backend\", \"app.py\");\n  if (!fs.existsSync(script)) {\n    console.warn(`backend/app.py not found: ${script}`);\n    return null;\n  }\n\n  const candidates = [];\n  if (process.env.STAR_BACKEND_PYTHON) candidates.push(process.env.STAR_BACKEND_PYTHON);\n  candidates.push(path.join(projectRoot, \".venv\", \"bin\", \"python\"));\n  candidates.push(\"python3\");\n  candidates.push(\"python\");\n\n  for (const bin of candidates) {\n    try {\n      const child = spawn(bin, [script], {\n        cwd: projectRoot,\n        stdio: \"inherit\",\n      });\n      console.log(`backend started with ${bin}`);\n      return child;\n    } catch (e) {\n      console.warn(`failed to spawn ${bin}: ${e.message}`);\n    }\n  }\n  return null;\n}\n\nfunction ensureElectronStandaloneSnapshot(projectRoot) {\n  const src = path.join(projectRoot, \"frontend\", \"index.html\");\n  const dst = path.join(projectRoot, \"frontend\", \"electron-standalone.html\");\n  if (!fs.existsSync(src)) return;\n  if (fs.existsSync(dst)) return;\n  try {\n    fs.copyFileSync(src, dst);\n    console.log(`created standalone snapshot: ${dst}`);\n  } catch (e) {\n    console.warn(`failed to create standalone snapshot: ${e.message}`);\n  }\n}\n\nfunction emitMini(event, payload) {\n  if (!miniWindow || miniWindow.isDestroyed()) return;\n  miniWindow.webContents.send(\"tauri:event\", { event, payload });\n}\n\nfunction emitMain(event, payload) {\n  if (!mainWindow || mainWindow.isDestroyed()) return;\n  mainWindow.webContents.send(\"tauri:event\", { event, payload });\n}\n\nasync function enterMiniMode(projectRoot) {\n  const snapshot = await readStateWithFallback(projectRoot).catch(() => null);\n  if (snapshot) emitMini(\"mini-sync-state\", { ...snapshot, ui_lang: currentUiLang });\n\n  if (mainWindow && !mainWindow.isDestroyed()) {\n    const bounds = mainWindow.getBounds();\n    if (miniWindow && !miniWindow.isDestroyed()) {\n      miniWindow.setBounds({ ...miniWindow.getBounds(), x: bounds.x, y: bounds.y });\n    }\n    mainWindow.hide();\n  }\n  if (miniWindow && !miniWindow.isDestroyed()) {\n    miniWindow.show();\n    miniWindow.focus();\n  }\n}\n\nasync function openFrontendAndQuit() {\n  await shell.openExternal(`${BACKEND_BASE_URL}/`);\n  app.quit();\n}\n\nfunction createAssetWindow(projectRoot) {\n  if (assetWindow && !assetWindow.isDestroyed()) {\n    assetWindow.show();\n    assetWindow.focus();\n    assetWindow.moveTop();\n    return assetWindow;\n  }\n\n  const preloadPath = path.join(__dirname, \"preload.js\");\n  const appIconPath = resolveAppIconPath(projectRoot);\n  const mainBounds = mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : null;\n  const x = mainBounds ? mainBounds.x + 32 : 160;\n  const y = mainBounds ? mainBounds.y + 32 : 120;\n  const assetUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&assetWindow=1`;\n\n  assetWindow = new BrowserWindow({\n    width: 300,\n    height: 580,\n    minWidth: 300,\n    maxWidth: 300,\n    minHeight: 580,\n    x,\n    y,\n    title: \"Star Decorate Room\",\n    frame: false,\n    transparent: true,\n    hasShadow: false,\n    alwaysOnTop: true,\n    resizable: true,\n    maximizable: true,\n    fullscreenable: false,\n    backgroundColor: \"#00000000\",\n    icon: appIconPath || undefined,\n    show: false,\n    webPreferences: {\n      preload: preloadPath,\n      contextIsolation: true,\n      nodeIntegration: false,\n    },\n  });\n\n  assetWindow.once(\"ready-to-show\", () => {\n    if (!assetWindow || assetWindow.isDestroyed()) return;\n    assetWindow.setAlwaysOnTop(true, \"floating\");\n    assetWindow.moveTop();\n    assetWindow.show();\n    assetWindow.focus();\n  });\n  assetWindow.on(\"closed\", () => {\n    assetWindow = null;\n  });\n  assetWindow.loadURL(assetUrl);\n  return assetWindow;\n}\n\nfunction createWindows(projectRoot) {\n  const preloadPath = path.join(__dirname, \"preload.js\");\n  const appIconPath = resolveAppIconPath(projectRoot);\n  ensureElectronStandaloneSnapshot(projectRoot);\n\n  mainWindow = new BrowserWindow({\n    width: 700,\n    height: 460,\n    x: 80,\n    y: 60,\n    transparent: true,\n    frame: false,\n    alwaysOnTop: true,\n    resizable: false,\n    maximizable: false,\n    fullscreenable: false,\n    hasShadow: false,\n    icon: appIconPath || undefined,\n    webPreferences: {\n      preload: preloadPath,\n      contextIsolation: true,\n      nodeIntegration: false,\n    },\n  });\n  mainWindow.setTitle(APP_NAME);\n\n  miniWindow = new BrowserWindow({\n    width: 220,\n    height: 240,\n    minWidth: 180,\n    minHeight: 200,\n    transparent: true,\n    frame: false,\n    alwaysOnTop: true,\n    resizable: false,\n    hasShadow: false,\n    show: false,\n    webPreferences: {\n      preload: preloadPath,\n      contextIsolation: true,\n      nodeIntegration: false,\n    },\n  });\n  miniWindow.setTitle(\"Star Office UI Mini\");\n\n  const v = Date.now();\n  const mainUrl = `${BACKEND_BASE_URL}/electron-standalone?desktop=1&v=${v}`;\n  mainWindow.loadURL(mainUrl);\n  miniWindow.loadFile(path.join(projectRoot, \"desktop-pet\", \"src\", \"minimized.html\"));\n}\n\nfunction createTray(projectRoot) {\n  const tray32 = path.join(projectRoot, \"desktop-pet\", \"src-tauri\", \"icons\", \"32x32.png\");\n  const iconPath = fs.existsSync(tray32) ? tray32 : resolveAppIconPath(projectRoot);\n  if (!iconPath) return;\n  const trayImage = nativeImage.createFromPath(iconPath);\n  tray = new Tray(trayImage);\n  tray.setToolTip(APP_NAME);\n\n  const menu = Menu.buildFromTemplate([\n    {\n      label: \"显示主窗口\",\n      click: () => {\n        if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();\n        if (mainWindow && !mainWindow.isDestroyed()) {\n          mainWindow.show();\n          mainWindow.focus();\n        }\n      },\n    },\n    {\n      label: \"显示 Mini 窗口\",\n      click: () => {\n        if (mainWindow && !mainWindow.isDestroyed()) mainWindow.hide();\n        if (miniWindow && !miniWindow.isDestroyed()) {\n          miniWindow.show();\n          miniWindow.focus();\n        }\n      },\n    },\n    { type: \"separator\" },\n    {\n      label: \"退出\",\n      click: () => app.quit(),\n    },\n  ]);\n\n  tray.setContextMenu(menu);\n  tray.on(\"click\", () => {\n    if (!mainWindow || mainWindow.isDestroyed()) return;\n    if (mainWindow.isVisible()) mainWindow.hide();\n    else {\n      if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();\n      mainWindow.show();\n      mainWindow.focus();\n    }\n  });\n}\n\nfunction registerIpc(projectRoot) {\n  const applyMainWindowMode = (expanded) => {\n    if (!mainWindow || mainWindow.isDestroyed()) return;\n    const bounds = mainWindow.getBounds();\n    const targetHeight = expanded ? 620 : 460;\n    const targetWidth = bounds.width || 700;\n    mainWindow.setSize(targetWidth, targetHeight, true);\n    mainWindow.setContentSize(targetWidth, targetHeight, true);\n  };\n\n  ipcMain.handle(\"tauri:invoke\", async (_event, payload) => {\n    const cmd = payload && payload.command;\n    const args = (payload && payload.args) || {};\n\n    if (cmd === \"read_state\") {\n      const state = await readStateWithFallback(projectRoot);\n      return { ...state, ui_lang: currentUiLang };\n    }\n\n    if (cmd === \"set_ui_lang\") {\n      const lang = String(args && args.lang ? args.lang : \"\").toLowerCase();\n      if (lang === \"zh\" || lang === \"en\" || lang === \"ja\") {\n        currentUiLang = lang;\n      }\n      return { ok: true, lang: currentUiLang };\n    }\n\n    if (cmd === \"enter_minimize_mode\") {\n      await enterMiniMode(projectRoot);\n      return null;\n    }\n\n    if (cmd === \"restore_main_window\") {\n      if (miniWindow && !miniWindow.isDestroyed()) miniWindow.hide();\n      if (mainWindow && !mainWindow.isDestroyed()) {\n        mainWindow.show();\n        mainWindow.focus();\n      }\n      return null;\n    }\n\n    if (cmd === \"close_app\") {\n      app.quit();\n      return null;\n    }\n\n    if (cmd === \"open_external_url\") {\n      if (args && args.url) {\n        await shell.openExternal(args.url);\n      }\n      return null;\n    }\n\n    if (cmd === \"set_main_window_mode\") {\n      const senderWin = BrowserWindow.fromWebContents(_event.sender);\n      // Only main window is allowed to control main window height.\n      if (!senderWin || !mainWindow || senderWin.id !== mainWindow.id) {\n        return null;\n      }\n      const expanded = !!(args && args.expanded);\n      applyMainWindowMode(expanded);\n      return null;\n    }\n\n    if (cmd === \"open_asset_window\") {\n      createAssetWindow(projectRoot);\n      return null;\n    }\n\n    if (cmd === \"close_asset_window\") {\n      if (assetWindow && !assetWindow.isDestroyed()) {\n        assetWindow.close();\n      }\n      return null;\n    }\n\n    if (cmd === \"notify_main_window_asset_refresh\") {\n      const payloadData = {\n        ...(args && typeof args === \"object\" ? args : {}),\n      };\n      const kind = String(payloadData.kind ? payloadData.kind : \"asset\");\n      const path = String(payloadData.path ? payloadData.path : \"\");\n      emitMain(\"main-window-asset-refresh\", {\n        ...payloadData,\n        kind,\n        path,\n        at: Date.now(),\n      });\n      return null;\n    }\n\n    throw new Error(`Unsupported invoke command: ${cmd}`);\n  });\n\n  ipcMain.handle(\"window:set-size\", (event, payload) => {\n    const win = BrowserWindow.fromWebContents(event.sender);\n    if (!win) return null;\n    const width = Number(payload && payload.width);\n    const height = Number(payload && payload.height);\n    if (Number.isFinite(width) && Number.isFinite(height)) {\n      const w = Math.round(width);\n      const h = Math.round(height);\n      // Dual strategy: outer-size and content-size together for transparent frameless windows.\n      win.setSize(w, h, true);\n      win.setContentSize(w, h, true);\n    }\n    return null;\n  });\n\n  ipcMain.handle(\"window:get-position\", (event) => {\n    const win = BrowserWindow.fromWebContents(event.sender);\n    if (!win) return { x: 0, y: 0 };\n    const [x, y] = win.getPosition();\n    return { x, y };\n  });\n\n  ipcMain.handle(\"window:set-position\", (event, payload) => {\n    const win = BrowserWindow.fromWebContents(event.sender);\n    if (!win) return null;\n    const x = Number(payload && payload.x);\n    const y = Number(payload && payload.y);\n    if (Number.isFinite(x) && Number.isFinite(y)) {\n      win.setPosition(Math.round(x), Math.round(y), false);\n    }\n    return null;\n  });\n}\n\nasync function bootstrap() {\n  const projectRoot = findProjectRoot();\n  console.log(`project root: ${projectRoot}`);\n  console.log(`state path: ${path.join(projectRoot, \"state.json\")}`);\n  const iconPath = applyAppIcon(projectRoot);\n  if (iconPath) console.log(`app icon: ${iconPath}`);\n\n  if (!(await tcpReachable(BACKEND_HOST, BACKEND_PORT, 400))) {\n    backendChild = spawnBackend(projectRoot);\n    const ready = await waitBackendReady(20000);\n    if (!ready) console.warn(\"backend not ready within 20s\");\n  } else {\n    console.log(`backend already running on ${BACKEND_HOST}:${BACKEND_PORT}`);\n  }\n\n  registerIpc(projectRoot);\n  createWindows(projectRoot);\n  createTray(projectRoot);\n}\n\napp.on(\"window-all-closed\", (e) => {\n  // Keep tray app resident by default (unless quitting).\n  if (!isQuitting) e.preventDefault();\n});\n\napp.on(\"before-quit\", () => {\n  isQuitting = true;\n  if (backendChild) {\n    try {\n      backendChild.kill();\n    } catch (_) {}\n  }\n});\n\nif (app.setName) app.setName(APP_NAME);\napp.whenReady().then(bootstrap);\n"
  },
  {
    "path": "electron-shell/package.json",
    "content": "{\n  \"name\": \"star-office-ui\",\n  \"productName\": \"Star Office UI\",\n  \"private\": true,\n  \"version\": \"0.1.0\",\n  \"main\": \"main.js\",\n  \"scripts\": {\n    \"dev\": \"electron .\",\n    \"start\": \"electron .\"\n  },\n  \"dependencies\": {\n    \"electron\": \"^40.6.1\"\n  }\n}\n"
  },
  {
    "path": "electron-shell/preload.js",
    "content": "const { contextBridge, ipcRenderer } = require(\"electron\");\n\nconst listeners = new Map();\n\nipcRenderer.on(\"tauri:event\", (_event, data) => {\n  const eventName = data && data.event;\n  if (!eventName) return;\n  const subs = listeners.get(eventName) || [];\n  for (const cb of subs) {\n    try {\n      cb({ payload: data.payload });\n    } catch (_) {}\n  }\n});\n\nclass LogicalSize {\n  constructor(width, height) {\n    this.width = width;\n    this.height = height;\n  }\n}\n\nlet dragging = false;\nlet dragStartPointer = null;\nlet dragStartWindow = null;\nlet dragMoveBound = false;\nlet lastMouseScreen = { x: 0, y: 0 };\n\nfunction ensureDragMoveHandlers() {\n  if (dragMoveBound) return;\n  dragMoveBound = true;\n\n  window.addEventListener(\"mousemove\", async (e) => {\n    lastMouseScreen = { x: e.screenX, y: e.screenY };\n    if (!dragging || !dragStartPointer || !dragStartWindow) return;\n    const dx = e.screenX - dragStartPointer.x;\n    const dy = e.screenY - dragStartPointer.y;\n    await ipcRenderer.invoke(\"window:set-position\", {\n      x: dragStartWindow.x + dx,\n      y: dragStartWindow.y + dy,\n    });\n  });\n\n  const stopDrag = () => {\n    dragging = false;\n    dragStartPointer = null;\n    dragStartWindow = null;\n  };\n  window.addEventListener(\"mouseup\", stopDrag);\n  window.addEventListener(\"blur\", stopDrag);\n}\n\nconst tauriCompat = {\n  core: {\n    invoke: (command, args = {}) =>\n      ipcRenderer.invoke(\"tauri:invoke\", { command, args }),\n  },\n  event: {\n    listen: async (eventName, callback) => {\n      const subs = listeners.get(eventName) || [];\n      subs.push(callback);\n      listeners.set(eventName, subs);\n      return () => {\n        const cur = listeners.get(eventName) || [];\n        listeners.set(\n          eventName,\n          cur.filter((x) => x !== callback),\n        );\n      };\n    },\n  },\n  window: {\n    getCurrentWindow: () => ({\n      startDragging: async () => {\n        ensureDragMoveHandlers();\n        const pos = await ipcRenderer.invoke(\"window:get-position\");\n        dragStartWindow = {\n          x: Number(pos && pos.x) || 0,\n          y: Number(pos && pos.y) || 0,\n        };\n        dragStartPointer = {\n          x: lastMouseScreen.x,\n          y: lastMouseScreen.y,\n        };\n        dragging = true;\n        return null;\n      },\n      setSize: async (logicalSize) =>\n        ipcRenderer.invoke(\"window:set-size\", {\n          width: logicalSize && logicalSize.width,\n          height: logicalSize && logicalSize.height,\n        }),\n      close: async () => tauriCompat.core.invoke(\"close_app\"),\n      hide: async () => null,\n      show: async () => null,\n      setFocus: async () => null,\n    }),\n  },\n  dpi: {\n    LogicalSize,\n  },\n};\n\ncontextBridge.exposeInMainWorld(\"__TAURI__\", tauriCompat);\ncontextBridge.exposeInMainWorld(\"__ELECTRON__\", {\n  invoke: tauriCompat.core.invoke,\n});\n"
  },
  {
    "path": "electron-shell/standalone-assets/game.js",
    "content": "// Star Office UI - 游戏主逻辑\n// 依赖: layout.js（必须在这个之前加载）\n\n// 检测浏览器是否支持 WebP\nlet supportsWebP = false;\n\n// 方法 1: 使用 canvas 检测\nfunction checkWebPSupport() {\n  return new Promise((resolve) => {\n    const canvas = document.createElement('canvas');\n    if (canvas.getContext && canvas.getContext('2d')) {\n      resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0);\n    } else {\n      resolve(false);\n    }\n  });\n}\n\n// 方法 2: 使用 image 检测（备用）\nfunction checkWebPSupportFallback() {\n  return new Promise((resolve) => {\n    const img = new Image();\n    img.onload = () => resolve(true);\n    img.onerror = () => resolve(false);\n    img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA==';\n  });\n}\n\n// 获取文件扩展名（根据 WebP 支持情况 + 布局配置的 forcePng）\nfunction getExt(pngFile) {\n  // star-working-spritesheet.png 太宽了，WebP 不支持，始终用 PNG\n  if (pngFile === 'star-working-spritesheet.png') {\n    return '.png';\n  }\n  // 如果布局配置里强制用 PNG，就用 .png\n  if (LAYOUT.forcePng && LAYOUT.forcePng[pngFile.replace(/\\.(png|webp)$/, '')]) {\n    return '.png';\n  }\n  return supportsWebP ? '.webp' : '.png';\n}\n\nconst config = {\n  type: Phaser.AUTO,\n  width: LAYOUT.game.width,\n  height: LAYOUT.game.height,\n  parent: 'game-container',\n  pixelArt: true,\n  physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },\n  scene: { preload: preload, create: create, update: update }\n};\n\nlet totalAssets = 0;\nlet loadedAssets = 0;\nlet loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText;\n\n// Memo 相关函数\nasync function loadMemo() {\n  const memoDate = document.getElementById('memo-date');\n  const memoContent = document.getElementById('memo-content');\n\n  try {\n    const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' });\n    const data = await response.json();\n\n    if (data.success && data.memo) {\n      memoDate.textContent = data.date || '';\n      memoContent.innerHTML = data.memo.replace(/\\n/g, '<br>');\n    } else {\n      memoContent.innerHTML = '<div id=\"memo-placeholder\">暂无昨日日记</div>';\n    }\n  } catch (e) {\n    console.error('加载 memo 失败:', e);\n    memoContent.innerHTML = '<div id=\"memo-placeholder\">加载失败</div>';\n  }\n}\n\n// 更新加载进度\nfunction updateLoadingProgress() {\n  loadedAssets++;\n  const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100));\n  if (loadingProgressBar) {\n    loadingProgressBar.style.width = percent + '%';\n  }\n  if (loadingText) {\n    loadingText.textContent = `正在加载 Star 的像素办公室... ${percent}%`;\n  }\n}\n\n// 隐藏加载界面\nfunction hideLoadingOverlay() {\n  setTimeout(() => {\n    if (loadingOverlay) {\n      loadingOverlay.style.transition = 'opacity 0.5s ease';\n      loadingOverlay.style.opacity = '0';\n      setTimeout(() => {\n        loadingOverlay.style.display = 'none';\n      }, 500);\n    }\n  }, 300);\n}\n\nconst STATES = {\n  idle: { name: '待命', area: 'breakroom' },\n  writing: { name: '整理文档', area: 'writing' },\n  researching: { name: '搜索信息', area: 'researching' },\n  executing: { name: '执行任务', area: 'writing' },\n  syncing: { name: '同步备份', area: 'writing' },\n  error: { name: '出错了', area: 'error' }\n};\n\nconst BUBBLE_TEXTS = {\n  idle: [\n    '待命中：耳朵竖起来了',\n    '我在这儿，随时可以开工',\n    '先把桌面收拾干净再说',\n    '呼——给大脑放个风',\n    '今天也要优雅地高效',\n    '等待，是为了更准确的一击',\n    '咖啡还热，灵感也还在',\n    '我在后台给你加 Buff',\n    '状态：静心 / 充电',\n    '小猫说：慢一点也没关系'\n  ],\n  writing: [\n    '进入专注模式：勿扰',\n    '先把关键路径跑通',\n    '我来把复杂变简单',\n    '把 bug 关进笼子里',\n    '写到一半，先保存',\n    '把每一步都做成可回滚',\n    '今天的进度，明天的底气',\n    '先收敛，再发散',\n    '让系统变得更可解释',\n    '稳住，我们能赢'\n  ],\n  researching: [\n    '我在挖证据链',\n    '让我把信息熬成结论',\n    '找到了：关键在这里',\n    '先把变量控制住',\n    '我在查：它为什么会这样',\n    '把直觉写成验证',\n    '先定位，再优化',\n    '别急，先画因果图'\n  ],\n  executing: [\n    '执行中：不要眨眼',\n    '把任务切成小块逐个击破',\n    '开始跑 pipeline',\n    '一键推进：走你',\n    '让结果自己说话',\n    '先做最小可行，再做最美版本'\n  ],\n  syncing: [\n    '同步中：把今天锁进云里',\n    '备份不是仪式，是安全感',\n    '写入中…别断电',\n    '把变更交给时间戳',\n    '云端对齐：咔哒',\n    '同步完成前先别乱动',\n    '把未来的自己从灾难里救出来',\n    '多一份备份，少一份后悔'\n  ],\n  error: [\n    '警报响了：先别慌',\n    '我闻到 bug 的味道了',\n    '先复现，再谈修复',\n    '把日志给我，我会说人话',\n    '错误不是敌人，是线索',\n    '把影响面圈起来',\n    '先止血，再手术',\n    '我在：马上定位根因',\n    '别怕，这种我见多了',\n    '报警中：让问题自己现形'\n  ],\n  cat: [\n    '喵~',\n    '咕噜咕噜…',\n    '尾巴摇一摇',\n    '晒太阳最开心',\n    '有人来看我啦',\n    '我是这个办公室的吉祥物',\n    '伸个懒腰',\n    '今天的罐罐准备好了吗',\n    '呼噜呼噜',\n    '这个位置视野最好'\n  ]\n};\n\nlet game, star, sofa, serverroom, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, catBubble = null;\nlet isMoving = false;\nlet waypoints = [];\nlet lastWanderAt = 0;\nlet coordsOverlay, coordsDisplay, coordsToggle;\nlet showCoords = false;\nconst FETCH_INTERVAL = 2000;\nconst BLINK_INTERVAL = 2500;\nconst BUBBLE_INTERVAL = 8000;\nconst CAT_BUBBLE_INTERVAL = 18000;\nlet lastCatBubble = 0;\nconst TYPEWRITER_DELAY = 50;\nlet agents = {}; // agentId -> sprite/container\nlet lastAgentsFetch = 0;\nconst AGENTS_FETCH_INTERVAL = 2500;\n\n// agent 颜色配置\nconst AGENT_COLORS = {\n  star: 0xffd700,\n  npc1: 0x00aaff,\n  agent_nika: 0xff69b4,\n  default: 0x94a3b8\n};\n\n// agent 名字颜色\nconst NAME_TAG_COLORS = {\n  approved: 0x22c55e,\n  pending: 0xf59e0b,\n  rejected: 0xef4444,\n  offline: 0x64748b,\n  default: 0x1f2937\n};\n\n// breakroom / writing / error 区域的 agent 分布位置（多 agent 时错开）\nconst AREA_POSITIONS = {\n  breakroom: [\n    { x: 620, y: 180 },\n    { x: 560, y: 220 },\n    { x: 680, y: 210 }\n  ],\n  writing: [\n    { x: 760, y: 320 },\n    { x: 830, y: 280 },\n    { x: 690, y: 350 }\n  ],\n  error: [\n    { x: 180, y: 260 },\n    { x: 120, y: 220 },\n    { x: 240, y: 230 }\n  ]\n};\n\nlet areaPositionCounters = { breakroom: 0, writing: 0, error: 0 };\n\n\n// 状态控制栏函数（用于测试）\nfunction setState(state, detail) {\n  fetch('/set_state', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ state, detail })\n  }).then(() => fetchStatus());\n}\n\n// 初始化：先检测 WebP 支持，再启动游戏\nasync function initGame() {\n  try {\n    supportsWebP = await checkWebPSupport();\n  } catch (e) {\n    try {\n      supportsWebP = await checkWebPSupportFallback();\n    } catch (e2) {\n      supportsWebP = false;\n    }\n  }\n\n  console.log('WebP 支持:', supportsWebP);\n  new Phaser.Game(config);\n}\n\nfunction preload() {\n  loadingOverlay = document.getElementById('loading-overlay');\n  loadingProgressBar = document.getElementById('loading-progress-bar');\n  loadingText = document.getElementById('loading-text');\n  loadingProgressContainer = document.getElementById('loading-progress-container');\n\n  // 从 LAYOUT 读取总资源数量（避免 magic number）\n  totalAssets = LAYOUT.totalAssets || 15;\n  loadedAssets = 0;\n\n  this.load.on('filecomplete', () => {\n    updateLoadingProgress();\n  });\n\n  this.load.on('complete', () => {\n    hideLoadingOverlay();\n  });\n\n  this.load.image('office_bg', '/static/office_bg_small' + (supportsWebP ? '.webp' : '.png') + '?v={{VERSION_TIMESTAMP}}');\n  this.load.spritesheet('star_idle', '/static/star-idle-spritesheet' + getExt('star-idle-spritesheet.png'), { frameWidth: 128, frameHeight: 128 });\n  this.load.spritesheet('star_researching', '/static/star-researching-spritesheet' + getExt('star-researching-spritesheet.png'), { frameWidth: 128, frameHeight: 105 });\n\n  this.load.image('sofa_idle', '/static/sofa-idle' + getExt('sofa-idle.png'));\n  this.load.spritesheet('sofa_busy', '/static/sofa-busy-spritesheet' + getExt('sofa-busy-spritesheet.png'), { frameWidth: 256, frameHeight: 256 });\n\n  this.load.spritesheet('plants', '/static/plants-spritesheet' + getExt('plants-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });\n  this.load.spritesheet('posters', '/static/posters-spritesheet' + getExt('posters-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });\n  this.load.spritesheet('coffee_machine', '/static/coffee-machine-spritesheet' + getExt('coffee-machine-spritesheet.png'), { frameWidth: 230, frameHeight: 230 });\n  this.load.spritesheet('serverroom', '/static/serverroom-spritesheet' + getExt('serverroom-spritesheet.png'), { frameWidth: 180, frameHeight: 251 });\n\n  this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 180, frameHeight: 180 });\n  this.load.spritesheet('cats', '/static/cats-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 160, frameHeight: 160 });\n  this.load.image('desk', '/static/desk' + getExt('desk.png'));\n  this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 230, frameHeight: 144 });\n  this.load.spritesheet('sync_anim', '/static/sync-animation-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 256, frameHeight: 256 });\n  this.load.image('memo_bg', '/static/memo-bg' + (supportsWebP ? '.webp' : '.png'));\n\n  // 新办公桌：强制 PNG（透明）\n  this.load.image('desk_v2', '/static/desk-v2.png');\n  this.load.spritesheet('flowers', '/static/flowers-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 65, frameHeight: 65 });\n}\n\nfunction create() {\n  game = this;\n  this.add.image(640, 360, 'office_bg');\n\n  // === 沙发（来自 LAYOUT）===\n  sofa = this.add.sprite(\n    LAYOUT.furniture.sofa.x,\n    LAYOUT.furniture.sofa.y,\n    'sofa_busy'\n  ).setOrigin(LAYOUT.furniture.sofa.origin.x, LAYOUT.furniture.sofa.origin.y);\n  sofa.setDepth(LAYOUT.furniture.sofa.depth);\n\n  this.anims.create({\n    key: 'sofa_busy',\n    frames: this.anims.generateFrameNumbers('sofa_busy', { start: 0, end: 47 }),\n    frameRate: 12,\n    repeat: -1\n  });\n\n  areas = LAYOUT.areas;\n\n  this.anims.create({\n    key: 'star_idle',\n    frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: 29 }),\n    frameRate: 12,\n    repeat: -1\n  });\n  this.anims.create({\n    key: 'star_researching',\n    frames: this.anims.generateFrameNumbers('star_researching', { start: 0, end: 95 }),\n    frameRate: 12,\n    repeat: -1\n  });\n\n  star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle');\n  star.setOrigin(0.5);\n  star.setScale(1.4);\n  star.setAlpha(0.95);\n  star.setDepth(20);\n  star.setVisible(false);\n  star.anims.stop();\n\n  if (game.textures.exists('sofa_busy')) {\n    sofa.setTexture('sofa_busy');\n    sofa.anims.play('sofa_busy', true);\n  }\n\n  // === 牌匾（来自 LAYOUT）===\n  const plaqueX = LAYOUT.plaque.x;\n  const plaqueY = LAYOUT.plaque.y;\n  const plaqueBg = game.add.rectangle(plaqueX, plaqueY, LAYOUT.plaque.width, LAYOUT.plaque.height, 0x5d4037);\n  plaqueBg.setStrokeStyle(3, 0x3e2723);\n  const plaqueText = game.add.text(plaqueX, plaqueY, '海辛小龙虾的办公室', {\n    fontFamily: 'ArkPixel, monospace',\n    fontSize: '18px',\n    fill: '#ffd700',\n    fontWeight: 'bold',\n    stroke: '#000',\n    strokeThickness: 2\n  }).setOrigin(0.5);\n  game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);\n  game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);\n\n  // === 植物们（来自 LAYOUT）===\n  const plantFrameCount = 16;\n  for (let i = 0; i < LAYOUT.furniture.plants.length; i++) {\n    const p = LAYOUT.furniture.plants[i];\n    const randomPlantFrame = Math.floor(Math.random() * plantFrameCount);\n    const plant = game.add.sprite(p.x, p.y, 'plants', randomPlantFrame).setOrigin(0.5);\n    plant.setDepth(p.depth);\n    plant.setInteractive({ useHandCursor: true });\n    window[`plantSprite${i === 0 ? '' : i + 1}`] = plant;\n    plant.on('pointerdown', (() => {\n      const next = Math.floor(Math.random() * plantFrameCount);\n      plant.setFrame(next);\n    }));\n  }\n\n  // === 海报（来自 LAYOUT）===\n  const postersFrameCount = 32;\n  const randomPosterFrame = Math.floor(Math.random() * postersFrameCount);\n  const poster = game.add.sprite(LAYOUT.furniture.poster.x, LAYOUT.furniture.poster.y, 'posters', randomPosterFrame).setOrigin(0.5);\n  poster.setDepth(LAYOUT.furniture.poster.depth);\n  poster.setInteractive({ useHandCursor: true });\n  window.posterSprite = poster;\n  window.posterFrameCount = postersFrameCount;\n  poster.on('pointerdown', () => {\n    const next = Math.floor(Math.random() * window.posterFrameCount);\n    window.posterSprite.setFrame(next);\n  });\n\n  // === 小猫（来自 LAYOUT）===\n  const catsFrameCount = 16;\n  const randomCatFrame = Math.floor(Math.random() * catsFrameCount);\n  const cat = game.add.sprite(LAYOUT.furniture.cat.x, LAYOUT.furniture.cat.y, 'cats', randomCatFrame).setOrigin(LAYOUT.furniture.cat.origin.x, LAYOUT.furniture.cat.origin.y);\n  cat.setDepth(LAYOUT.furniture.cat.depth);\n  cat.setInteractive({ useHandCursor: true });\n  window.catSprite = cat;\n  window.catsFrameCount = catsFrameCount;\n  cat.on('pointerdown', () => {\n    const next = Math.floor(Math.random() * window.catsFrameCount);\n    window.catSprite.setFrame(next);\n  });\n\n  // === 咖啡机（来自 LAYOUT）===\n  this.anims.create({\n    key: 'coffee_machine',\n    frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: 95 }),\n    frameRate: 12.5,\n    repeat: -1\n  });\n  const coffeeMachine = this.add.sprite(\n    LAYOUT.furniture.coffeeMachine.x,\n    LAYOUT.furniture.coffeeMachine.y,\n    'coffee_machine'\n  ).setOrigin(LAYOUT.furniture.coffeeMachine.origin.x, LAYOUT.furniture.coffeeMachine.origin.y);\n  coffeeMachine.setDepth(LAYOUT.furniture.coffeeMachine.depth);\n  coffeeMachine.anims.play('coffee_machine', true);\n\n  // === 服务器区（来自 LAYOUT）===\n  this.anims.create({\n    key: 'serverroom_on',\n    frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: 39 }),\n    frameRate: 6,\n    repeat: -1\n  });\n  serverroom = this.add.sprite(\n    LAYOUT.furniture.serverroom.x,\n    LAYOUT.furniture.serverroom.y,\n    'serverroom',\n    0\n  ).setOrigin(LAYOUT.furniture.serverroom.origin.x, LAYOUT.furniture.serverroom.origin.y);\n  serverroom.setDepth(LAYOUT.furniture.serverroom.depth);\n  serverroom.anims.stop();\n  serverroom.setFrame(0);\n\n  // === 新办公桌（来自 LAYOUT，强制透明 PNG）===\n  const desk = this.add.image(\n    LAYOUT.furniture.desk.x,\n    LAYOUT.furniture.desk.y,\n    'desk_v2'\n  ).setOrigin(LAYOUT.furniture.desk.origin.x, LAYOUT.furniture.desk.origin.y);\n  desk.setDepth(LAYOUT.furniture.desk.depth);\n\n  // === 花盆（来自 LAYOUT）===\n  const flowerFrameCount = 16;\n  const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount);\n  const flower = this.add.sprite(\n    LAYOUT.furniture.flower.x,\n    LAYOUT.furniture.flower.y,\n    'flowers',\n    randomFlowerFrame\n  ).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y);\n  flower.setScale(LAYOUT.furniture.flower.scale || 1);\n  flower.setDepth(LAYOUT.furniture.flower.depth);\n  flower.setInteractive({ useHandCursor: true });\n  window.flowerSprite = flower;\n  window.flowerFrameCount = flowerFrameCount;\n  flower.on('pointerdown', () => {\n    const next = Math.floor(Math.random() * window.flowerFrameCount);\n    window.flowerSprite.setFrame(next);\n  });\n\n  // === Star 在桌前工作（来自 LAYOUT）===\n  this.anims.create({\n    key: 'star_working',\n    frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 191 }),\n    frameRate: 12,\n    repeat: -1\n  });\n  this.anims.create({\n    key: 'error_bug',\n    frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 95 }),\n    frameRate: 12,\n    repeat: -1\n  });\n\n  // === 错误 bug（来自 LAYOUT）===\n  const errorBug = this.add.sprite(\n    LAYOUT.furniture.errorBug.x,\n    LAYOUT.furniture.errorBug.y,\n    'error_bug',\n    0\n  ).setOrigin(LAYOUT.furniture.errorBug.origin.x, LAYOUT.furniture.errorBug.origin.y);\n  errorBug.setDepth(LAYOUT.furniture.errorBug.depth);\n  errorBug.setVisible(false);\n  errorBug.setScale(LAYOUT.furniture.errorBug.scale);\n  errorBug.anims.play('error_bug', true);\n  window.errorBug = errorBug;\n  window.errorBugDir = 1;\n\n  const starWorking = this.add.sprite(\n    LAYOUT.furniture.starWorking.x,\n    LAYOUT.furniture.starWorking.y,\n    'star_working',\n    0\n  ).setOrigin(LAYOUT.furniture.starWorking.origin.x, LAYOUT.furniture.starWorking.origin.y);\n  starWorking.setVisible(false);\n  starWorking.setScale(LAYOUT.furniture.starWorking.scale);\n  starWorking.setDepth(LAYOUT.furniture.starWorking.depth);\n  window.starWorking = starWorking;\n\n  // === 同步动画（来自 LAYOUT）===\n  this.anims.create({\n    key: 'sync_anim',\n    frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }),\n    frameRate: 12,\n    repeat: -1\n  });\n  syncAnimSprite = this.add.sprite(\n    LAYOUT.furniture.syncAnim.x,\n    LAYOUT.furniture.syncAnim.y,\n    'sync_anim',\n    0\n  ).setOrigin(LAYOUT.furniture.syncAnim.origin.x, LAYOUT.furniture.syncAnim.origin.y);\n  syncAnimSprite.setDepth(LAYOUT.furniture.syncAnim.depth);\n  syncAnimSprite.anims.stop();\n  syncAnimSprite.setFrame(0);\n\n  window.starSprite = star;\n\n  statusText = document.getElementById('status-text');\n  coordsOverlay = document.getElementById('coords-overlay');\n  coordsDisplay = document.getElementById('coords-display');\n  coordsToggle = document.getElementById('coords-toggle');\n\n  coordsToggle.addEventListener('click', () => {\n    showCoords = !showCoords;\n    coordsOverlay.style.display = showCoords ? 'block' : 'none';\n    coordsToggle.textContent = showCoords ? '隐藏坐标' : '显示坐标';\n    coordsToggle.style.background = showCoords ? '#e94560' : '#333';\n  });\n\n  game.input.on('pointermove', (pointer) => {\n    if (!showCoords) return;\n    const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x)));\n    const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y)));\n    coordsDisplay.textContent = `${x}, ${y}`;\n    coordsOverlay.style.left = (pointer.x + 18) + 'px';\n    coordsOverlay.style.top = (pointer.y + 18) + 'px';\n  });\n\n  loadMemo();\n  fetchStatus();\n  // 先强制加一个测试用的尼卡 agent 渲染\n  const testNika = {\n    agentId: 'agent_nika',\n    name: '尼卡',\n    isMain: false,\n    state: 'writing',\n    detail: '在画像素画...',\n    area: 'writing',\n    authStatus: 'approved',\n    updated_at: new Date().toISOString()\n  };\n  renderAgent(testNika);\n  fetchAgents();\n\n  // 测试用：让尼卡模拟走来走去\n  window.testNikaState = 'writing';\n  window.testNikaTimer = setInterval(() => {\n    const states = ['idle', 'writing', 'researching', 'executing'];\n    const areas = { idle: 'breakroom', writing: 'writing', researching: 'writing', executing: 'writing' };\n    window.testNikaState = states[Math.floor(Math.random() * states.length)];\n    const testAgent = {\n      agentId: 'agent_nika',\n      name: '尼卡',\n      isMain: false,\n      state: window.testNikaState,\n      detail: '在画像素画...',\n      area: areas[window.testNikaState],\n      authStatus: 'approved',\n      updated_at: new Date().toISOString()\n    };\n    renderAgent(testAgent);\n  }, 5000);\n}\n\nfunction update(time) {\n  if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; }\n  if (time - lastAgentsFetch > AGENTS_FETCH_INTERVAL) { fetchAgents(); lastAgentsFetch = time; }\n\n  const effectiveStateForServer = pendingDesiredState || currentState;\n  if (serverroom) {\n    if (effectiveStateForServer === 'idle') {\n      if (serverroom.anims.isPlaying) {\n        serverroom.anims.stop();\n        serverroom.setFrame(0);\n      }\n    } else {\n      if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') {\n        serverroom.anims.play('serverroom_on', true);\n      }\n    }\n  }\n\n  if (window.errorBug) {\n    if (effectiveStateForServer === 'error') {\n      window.errorBug.setVisible(true);\n      if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') {\n        window.errorBug.anims.play('error_bug', true);\n      }\n      const leftX = LAYOUT.furniture.errorBug.pingPong.leftX;\n      const rightX = LAYOUT.furniture.errorBug.pingPong.rightX;\n      const speed = LAYOUT.furniture.errorBug.pingPong.speed;\n      const dir = window.errorBugDir || 1;\n      window.errorBug.x += speed * dir;\n      window.errorBug.y = LAYOUT.furniture.errorBug.y;\n      if (window.errorBug.x >= rightX) {\n        window.errorBug.x = rightX;\n        window.errorBugDir = -1;\n      } else if (window.errorBug.x <= leftX) {\n        window.errorBug.x = leftX;\n        window.errorBugDir = 1;\n      }\n    } else {\n      window.errorBug.setVisible(false);\n      window.errorBug.anims.stop();\n    }\n  }\n\n  if (syncAnimSprite) {\n    if (effectiveStateForServer === 'syncing') {\n      if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n        syncAnimSprite.anims.play('sync_anim', true);\n      }\n    } else {\n      if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n      syncAnimSprite.setFrame(0);\n    }\n  }\n\n  if (time - lastBubble > BUBBLE_INTERVAL) {\n    showBubble();\n    lastBubble = time;\n  }\n  if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) {\n    showCatBubble();\n    lastCatBubble = time;\n  }\n\n  if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) {\n    typewriterText += typewriterTarget[typewriterIndex];\n    statusText.textContent = typewriterText;\n    typewriterIndex++;\n    lastTypewriter = time;\n  }\n\n  moveStar(time);\n}\n\nfunction normalizeState(s) {\n  if (!s) return 'idle';\n  if (s === 'working') return 'writing';\n  if (s === 'run' || s === 'running') return 'executing';\n  if (s === 'sync') return 'syncing';\n  if (s === 'research') return 'researching';\n  return s;\n}\n\nfunction fetchStatus() {\n  fetch('/status')\n    .then(response => response.json())\n    .then(data => {\n      const nextState = normalizeState(data.state);\n      const stateInfo = STATES[nextState] || STATES.idle;\n      const changed = (pendingDesiredState === null) && (nextState !== currentState);\n      const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...');\n      if (changed) {\n        typewriterTarget = nextLine;\n        typewriterText = '';\n        typewriterIndex = 0;\n\n        pendingDesiredState = null;\n        currentState = nextState;\n\n        if (nextState === 'idle') {\n          if (game.textures.exists('sofa_busy')) {\n            sofa.setTexture('sofa_busy');\n            sofa.anims.play('sofa_busy', true);\n          }\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n        } else if (nextState === 'error') {\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n        } else if (nextState === 'syncing') {\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n        } else {\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(true);\n            window.starWorking.anims.play('star_working', true);\n          }\n        }\n\n        if (serverroom) {\n          if (nextState === 'idle') {\n            serverroom.anims.stop();\n            serverroom.setFrame(0);\n          } else {\n            serverroom.anims.play('serverroom_on', true);\n          }\n        }\n\n        if (syncAnimSprite) {\n          if (nextState === 'syncing') {\n            if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n              syncAnimSprite.anims.play('sync_anim', true);\n            }\n          } else {\n            if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n            syncAnimSprite.setFrame(0);\n          }\n        }\n      } else {\n        if (!typewriterTarget || typewriterTarget !== nextLine) {\n          typewriterTarget = nextLine;\n          typewriterText = '';\n          typewriterIndex = 0;\n        }\n      }\n    })\n    .catch(error => {\n      typewriterTarget = '连接失败，正在重试...';\n      typewriterText = '';\n      typewriterIndex = 0;\n    });\n}\n\nfunction moveStar(time) {\n  const effectiveState = pendingDesiredState || currentState;\n  const stateInfo = STATES[effectiveState] || STATES.idle;\n  const baseTarget = areas[stateInfo.area] || areas.breakroom;\n\n  const dx = targetX - star.x;\n  const dy = targetY - star.y;\n  const dist = Math.sqrt(dx * dx + dy * dy);\n  const speed = 1.4;\n  const wobble = Math.sin(time / 200) * 0.8;\n\n  if (dist > 3) {\n    star.x += (dx / dist) * speed;\n    star.y += (dy / dist) * speed;\n    star.setY(star.y + wobble);\n    isMoving = true;\n  } else {\n    if (waypoints && waypoints.length > 0) {\n      waypoints.shift();\n      if (waypoints.length > 0) {\n        targetX = waypoints[0].x;\n        targetY = waypoints[0].y;\n        isMoving = true;\n      } else {\n        if (pendingDesiredState !== null) {\n          isMoving = false;\n          currentState = pendingDesiredState;\n          pendingDesiredState = null;\n\n          if (currentState === 'idle') {\n            star.setVisible(false);\n            star.anims.stop();\n            if (window.starWorking) {\n              window.starWorking.setVisible(false);\n              window.starWorking.anims.stop();\n            }\n          } else {\n            star.setVisible(false);\n            star.anims.stop();\n            if (window.starWorking) {\n              window.starWorking.setVisible(true);\n              window.starWorking.anims.play('star_working', true);\n            }\n          }\n        }\n      }\n    } else {\n      if (pendingDesiredState !== null) {\n        isMoving = false;\n        currentState = pendingDesiredState;\n        pendingDesiredState = null;\n\n        if (currentState === 'idle') {\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n          if (game.textures.exists('sofa_busy')) {\n            sofa.setTexture('sofa_busy');\n            sofa.anims.play('sofa_busy', true);\n          }\n        } else {\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(true);\n            window.starWorking.anims.play('star_working', true);\n          }\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n        }\n      }\n    }\n  }\n}\n\nfunction showBubble() {\n  if (bubble) { bubble.destroy(); bubble = null; }\n  const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle;\n  if (currentState === 'idle') return;\n\n  let anchorX = star.x;\n  let anchorY = star.y;\n  if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) {\n    anchorX = syncAnimSprite.x;\n    anchorY = syncAnimSprite.y;\n  } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) {\n    anchorX = window.errorBug.x;\n    anchorY = window.errorBug.y;\n  } else if (!star.visible && window.starWorking && window.starWorking.visible) {\n    anchorX = window.starWorking.x;\n    anchorY = window.starWorking.y;\n  }\n\n  const text = texts[Math.floor(Math.random() * texts.length)];\n  const bubbleY = anchorY - 70;\n  const bg = game.add.rectangle(anchorX, bubbleY, text.length * 10 + 20, 28, 0xffffff, 0.95);\n  bg.setStrokeStyle(2, 0x000000);\n  const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '12px', fill: '#000', align: 'center' }).setOrigin(0.5);\n  bubble = game.add.container(0, 0, [bg, txt]);\n  bubble.setDepth(1200);\n  setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000);\n}\n\nfunction showCatBubble() {\n  if (!window.catSprite) return;\n  if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; }\n  const texts = BUBBLE_TEXTS.cat || ['喵~', '咕噜咕噜…'];\n  const text = texts[Math.floor(Math.random() * texts.length)];\n  const anchorX = window.catSprite.x;\n  const anchorY = window.catSprite.y - 60;\n  const bg = game.add.rectangle(anchorX, anchorY, text.length * 10 + 20, 24, 0xfffbeb, 0.95);\n  bg.setStrokeStyle(2, 0xd4a574);\n  const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5);\n  window.catBubble = game.add.container(0, 0, [bg, txt]);\n  window.catBubble.setDepth(2100);\n  setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000);\n}\n\nfunction fetchAgents() {\n  fetch('/agents?t=' + Date.now(), { cache: 'no-store' })\n    .then(response => response.json())\n    .then(data => {\n      if (!Array.isArray(data)) return;\n      // 重置位置计数器\n      areaPositionCounters = { breakroom: 0, writing: 0, error: 0 };\n      // 处理每个 agent\n      for (let agent of data) {\n        renderAgent(agent);\n      }\n      // 移除不再存在的 agent\n      const currentIds = new Set(data.map(a => a.agentId));\n      for (let id in agents) {\n        if (!currentIds.has(id)) {\n          if (agents[id]) {\n            agents[id].destroy();\n            delete agents[id];\n          }\n        }\n      }\n    })\n    .catch(error => {\n      console.error('拉取 agents 失败:', error);\n    });\n}\n\nfunction getAreaPosition(area) {\n  const positions = AREA_POSITIONS[area] || AREA_POSITIONS.breakroom;\n  const idx = areaPositionCounters[area] || 0;\n  areaPositionCounters[area] = (idx + 1) % positions.length;\n  return positions[idx];\n}\n\nfunction renderAgent(agent) {\n  const agentId = agent.agentId;\n  const name = agent.name || 'Agent';\n  const area = agent.area || 'breakroom';\n  const authStatus = agent.authStatus || 'pending';\n  const isMain = !!agent.isMain;\n\n  // 获取这个 agent 在区域里的位置\n  const pos = getAreaPosition(area);\n  const baseX = pos.x;\n  const baseY = pos.y;\n\n  // 颜色\n  const bodyColor = AGENT_COLORS[agentId] || AGENT_COLORS.default;\n  const nameColor = NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default;\n\n  // 透明度（离线/待批准/拒绝时变半透明）\n  let alpha = 1;\n  if (authStatus === 'pending') alpha = 0.7;\n  if (authStatus === 'rejected') alpha = 0.4;\n  if (authStatus === 'offline') alpha = 0.5;\n\n  if (!agents[agentId]) {\n    // 新建 agent\n    const container = game.add.container(baseX, baseY);\n    container.setDepth(1200 + (isMain ? 100 : 0)); // 放到最顶层！\n\n    // 像素小人：用星星图标，更明显\n    const starIcon = game.add.text(0, 0, '⭐', {\n      fontFamily: 'ArkPixel, monospace',\n      fontSize: '32px'\n    }).setOrigin(0.5);\n    starIcon.name = 'starIcon';\n\n    // 名字标签（漂浮）\n    const nameTag = game.add.text(0, -36, name, {\n      fontFamily: 'ArkPixel, monospace',\n      fontSize: '14px',\n      fill: '#' + nameColor.toString(16).padStart(6, '0'),\n      stroke: '#000',\n      strokeThickness: 3,\n      backgroundColor: 'rgba(255,255,255,0.95)'\n    }).setOrigin(0.5);\n    nameTag.name = 'nameTag';\n\n    // 状态小点（绿色/黄色/红色）\n    let dotColor = 0x64748b;\n    if (authStatus === 'approved') dotColor = 0x22c55e;\n    if (authStatus === 'pending') dotColor = 0xf59e0b;\n    if (authStatus === 'rejected') dotColor = 0xef4444;\n    if (authStatus === 'offline') dotColor = 0x94a3b8;\n    const statusDot = game.add.circle(20, -20, 5, dotColor, alpha);\n    statusDot.setStrokeStyle(2, 0x000000, alpha);\n    statusDot.name = 'statusDot';\n\n    container.add([starIcon, statusDot, nameTag]);\n    agents[agentId] = container;\n  } else {\n    // 更新 agent\n    const container = agents[agentId];\n    container.setPosition(baseX, baseY);\n    container.setAlpha(alpha);\n    container.setDepth(1200 + (isMain ? 100 : 0));\n\n    // 更新名字和颜色（如果变化）\n    const nameTag = container.getAt(2);\n    if (nameTag && nameTag.name === 'nameTag') {\n      nameTag.setText(name);\n      nameTag.setFill('#' + (NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default).toString(16).padStart(6, '0'));\n    }\n    // 更新状态点颜色\n    const statusDot = container.getAt(1);\n    if (statusDot && statusDot.name === 'statusDot') {\n      let dotColor = 0x64748b;\n      if (authStatus === 'approved') dotColor = 0x22c55e;\n      if (authStatus === 'pending') dotColor = 0xf59e0b;\n      if (authStatus === 'rejected') dotColor = 0xef4444;\n      if (authStatus === 'offline') dotColor = 0x94a3b8;\n      statusDot.fillColor = dotColor;\n    }\n  }\n}\n\n// 启动游戏\ninitGame();\n"
  },
  {
    "path": "electron-shell/standalone-assets/layout.js",
    "content": "// Star Office UI - 布局与层级配置\n// 所有坐标、depth、资源路径统一管理在这里\n// 避免 magic numbers，降低改错风险\n\n// 核心规则：\n// - 透明资源（如办公桌）强制 .png，不透明优先 .webp\n// - 层级：低 → sofa(10) → starWorking(900) → desk(1000) → flower(1100)\n\nconst LAYOUT = {\n  // === 游戏画布 ===\n  game: {\n    width: 1280,\n    height: 720\n  },\n\n  // === 各区域坐标 ===\n  areas: {\n    door:        { x: 640, y: 550 },\n    writing:     { x: 320, y: 360 },\n    researching: { x: 320, y: 360 },\n    error:       { x: 1066, y: 180 },\n    breakroom:   { x: 640, y: 360 }\n  },\n\n  // === 装饰与家具：坐标 + 原点 + depth ===\n  furniture: {\n    // 沙发\n    sofa: {\n      x: 670,\n      y: 144,\n      origin: { x: 0, y: 0 },\n      depth: 10\n    },\n\n    // 新办公桌（透明 PNG 强制）\n    desk: {\n      x: 218,\n      y: 417,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 1000\n    },\n\n    // 桌上花盆\n    flower: {\n      x: 310,\n      y: 390,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 1100,\n      scale: 0.8\n    },\n\n    // Star 在桌前工作（在 desk 下面）\n    starWorking: {\n      x: 217,\n      y: 333,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 900,\n      scale: 1.32\n    },\n\n    // 植物们\n    plants: [\n      { x: 565, y: 178, depth: 5 },\n      { x: 230, y: 185, depth: 5 },\n      { x: 977, y: 496, depth: 5 }\n    ],\n\n    // 海报\n    poster: {\n      x: 252,\n      y: 66,\n      depth: 4\n    },\n\n    // 咖啡机\n    coffeeMachine: {\n      x: 659,\n      y: 397,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 99\n    },\n\n    // 服务器区\n    serverroom: {\n      x: 1021,\n      y: 142,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 2\n    },\n\n    // 错误 bug\n    errorBug: {\n      x: 1007,\n      y: 221,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 50,\n      scale: 0.9,\n      pingPong: { leftX: 1007, rightX: 1111, speed: 0.6 }\n    },\n\n    // 同步动画\n    syncAnim: {\n      x: 1157,\n      y: 592,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 40\n    },\n\n    // 小猫\n    cat: {\n      x: 94,\n      y: 557,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 2000\n    }\n  },\n\n  // === 牌匾 ===\n  plaque: {\n    x: 640,\n    y: 720 - 36,\n    width: 420,\n    height: 44\n  },\n\n  // === 资源加载规则：哪些强制用 PNG（透明资源） ===\n  forcePng: {\n    desk_v2: true // 新办公桌必须透明，强制 PNG\n  },\n\n  // === 总资源数量（用于加载进度条） ===\n  totalAssets: 15\n};\n"
  },
  {
    "path": "frontend/electron-standalone.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Star 的像素办公室</title>\n    <style>\n        @font-face {\n            font-family: 'ArkPixel';\n            src: url('/static/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2') format('woff2');\n            font-weight: normal;\n            font-style: normal;\n        }\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        body {\n            background: #1a1a2e;\n            display: flex;\n            flex-direction: column;\n            justify-content: flex-start;\n            align-items: center;\n            min-height: 100vh;\n            font-family: 'ArkPixel', 'Courier New', monospace;\n            padding: 20px 0;\n            gap: 10px;\n            overflow-x: hidden;\n        }\n        body.asset-window-mode {\n            background: transparent !important;\n            padding: 0 !important;\n            gap: 0 !important;\n            min-height: 0 !important;\n            height: auto !important;\n            overflow: hidden !important;\n        }\n        body.asset-window-mode #window-controls,\n        body.asset-window-mode #status-fab,\n        body.asset-window-mode #loading-overlay,\n        body.asset-window-mode #main-stage,\n        body.asset-window-mode #bottom-panels,\n        body.asset-window-mode #asset-highlight,\n        body.asset-window-mode #room-loading-overlay,\n        body.asset-window-mode #coords-overlay,\n        body.asset-window-mode #coords-toggle,\n        body.asset-window-mode #pan-toggle,\n        body.asset-window-mode #lang-toggle-group {\n            display: none !important;\n        }\n        body.asset-window-mode #asset-drawer-backdrop {\n            display: none !important;\n        }\n        body.asset-window-mode #asset-drawer {\n            position: fixed !important;\n            inset: 0 !important;\n            width: auto !important;\n            height: 100vh !important;\n            max-width: none !important;\n            border-radius: 10px !important;\n            border: 0 !important;\n            box-shadow: 0 10px 30px rgba(0,0,0,0.45) !important;\n            background: rgba(17, 24, 39, 0.96) !important;\n            transform: none !important;\n            display: flex !important;\n            opacity: 1 !important;\n            pointer-events: auto !important;\n        }\n        body.asset-window-mode #asset-drawer-body {\n            flex: 1 1 auto !important;\n            min-height: 0 !important;\n            padding-bottom: 10px !important;\n        }\n        body.asset-window-mode #asset-drawer-header {\n            cursor: move !important;\n        }\n        button {\n            user-select: none;\n            -webkit-user-select: none;\n            -moz-user-select: none;\n            -ms-user-select: none;\n        }\n        /* Electron standalone overrides: replicate desktop-shell styles from pre-upstream version */\n        body.desktop-shell {\n            background: transparent !important;\n            --desktop-game-width: 684px;\n            --desktop-game-height: 384px;\n            padding: 6px 0 8px !important;\n            gap: 8px !important;\n            justify-content: flex-start !important;\n            overflow: hidden !important;\n        }\n        body.desktop-shell #main-stage {\n            width: var(--desktop-game-width) !important;\n        }\n        body.desktop-shell #game-container {\n            width: var(--desktop-game-width) !important;\n            height: var(--desktop-game-height) !important;\n            max-width: var(--desktop-game-width) !important;\n            max-height: var(--desktop-game-height) !important;\n            min-height: var(--desktop-game-height) !important;\n            aspect-ratio: 16 / 9 !important;\n            border-radius: 8px !important;\n            overflow: hidden !important;\n        }\n        body.desktop-shell #game-container canvas {\n            box-shadow: none !important;\n        }\n        body.desktop-shell #bottom-panels {\n            width: var(--desktop-game-width) !important;\n            max-width: var(--desktop-game-width) !important;\n            flex-direction: row !important;\n            gap: 8px !important;\n            flex-wrap: nowrap !important;\n            align-items: flex-start !important;\n            margin-top: 5px !important;\n        }\n        body.desktop-shell #memo-panel {\n            flex: 1 1 0 !important;\n            width: auto !important;\n            height: 132px !important;\n            padding: 6px 8px 8px !important;\n        }\n        body.desktop-shell #control-bar {\n            flex: 1 1 0 !important;\n            width: auto !important;\n            height: 132px !important;\n            padding: 6px 8px 8px !important;\n            gap: 6px !important;\n        }\n        body.desktop-shell #guest-agent-panel {\n            flex: 1 1 0 !important;\n            width: auto !important;\n            height: 132px !important;\n            padding: 6px 8px 8px !important;\n            gap: 6px !important;\n        }\n        body.desktop-shell #control-buttons {\n            display: grid;\n            grid-template-columns: repeat(2, minmax(0, 1fr));\n            gap: 6px !important;\n            align-items: stretch;\n            justify-items: stretch;\n        }\n        body.desktop-shell #control-buttons button {\n            height: 34px !important;\n            font-size: 11px !important;\n            padding: 4px 6px !important;\n        }\n        body.desktop-shell #control-bar-title,\n        body.desktop-shell #guest-agent-panel-title,\n        body.desktop-shell #memo-title {\n            font-size: 12px !important;\n        }\n        body.desktop-shell #guest-agent-list {\n            gap: 4px !important;\n        }\n        body.desktop-shell .guest-agent-item {\n            padding: 5px 6px !important;\n            gap: 4px !important;\n        }\n        body.desktop-shell .guest-agent-name {\n            font-size: 11px !important;\n        }\n        body.desktop-shell .guest-agent-buttons button {\n            font-size: 10px !important;\n            padding: 4px 6px !important;\n        }\n        body.desktop-shell #memo-date {\n            font-size: 9px !important;\n            left: -10px !important;\n        }\n        body.desktop-shell #memo-content {\n            font-size: 10px !important;\n            line-height: 1.55 !important;\n            left: 12px !important;\n        }\n        body.desktop-shell .panel-collapsible.collapsed {\n            height: 62px !important;\n            min-height: 62px !important;\n        }\n        body.desktop-shell .panel-collapsible:not(.collapsed) {\n            height: 220px !important;\n            min-height: 220px !important;\n        }\n        body.desktop-shell #status-text {\n            display: none !important;\n        }\n        #status-fab {\n            display: none;\n        }\n        body.desktop-shell #status-fab {\n            display: block;\n            position: fixed;\n            top: calc(env(safe-area-inset-top, 0px) + 10px);\n            left: 50%;\n            transform: translateX(-50%);\n            z-index: 1000001;\n            max-width: min(62vw, 520px);\n            padding: 6px 12px;\n            border-radius: 8px;\n            background: rgba(0, 0, 0, 0.72);\n            color: #eee;\n            font-size: 12px;\n            line-height: 1.25;\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n            pointer-events: none;\n        }\n        body.desktop-shell #loading-overlay {\n            background: rgba(0, 0, 0, 0.18) !important;\n            backdrop-filter: blur(2px);\n        }\n        body.desktop-shell #pan-toggle,\n        body.desktop-shell #coords-toggle {\n            display: none !important;\n        }\n        #window-controls {\n            display: none;\n        }\n        body.desktop-shell #window-controls {\n            display: flex;\n            position: fixed;\n            top: calc(env(safe-area-inset-top, 0px) + 10px);\n            left: 12px;\n            z-index: 1000002;\n            gap: 8px;\n        }\n        .traffic-btn {\n            width: 12px;\n            height: 12px;\n            border-radius: 50%;\n            border: none;\n            outline: none;\n            cursor: pointer;\n            box-shadow: 0 0 0 1px rgba(0,0,0,0.25) inset;\n            position: relative;\n            transition: transform 0.12s ease, filter 0.16s ease, box-shadow 0.16s ease;\n        }\n        .traffic-btn.close { background: #ff5f57; }\n        .traffic-btn.min { background: #febc2e; }\n        .traffic-btn.max { background: #28c840; opacity: 1; cursor: pointer; }\n        .traffic-btn:disabled { pointer-events: none; }\n        .traffic-btn::before,\n        .traffic-btn::after {\n            content: '';\n            position: absolute;\n            left: 50%;\n            top: 50%;\n            background: rgba(53, 53, 53, 0.9);\n            opacity: 0;\n            transition: opacity 0.14s ease;\n            transform-origin: center;\n        }\n        .traffic-btn.close::before,\n        .traffic-btn.close::after {\n            width: 7px;\n            height: 1.4px;\n            margin-left: -3.5px;\n            margin-top: -0.7px;\n            border-radius: 999px;\n            background: rgba(95, 24, 23, 0.95);\n        }\n        .traffic-btn.close::before { transform: rotate(45deg); }\n        .traffic-btn.close::after { transform: rotate(-45deg); }\n        .traffic-btn.min::before {\n            width: 7px;\n            height: 1.4px;\n            margin-left: -3.5px;\n            margin-top: -0.7px;\n            border-radius: 999px;\n            background: rgba(116, 83, 11, 0.95);\n        }\n        .traffic-btn.min::after { display: none; }\n        .traffic-btn.max::before {\n            width: 4px;\n            height: 4px;\n            left: 50%;\n            top: 50%;\n            margin-left: -2.3px;\n            margin-top: -2.3px;\n            background: rgba(28, 86, 38, 0.96);\n            clip-path: polygon(0 0, 100% 0, 0 100%);\n            transform: none;\n        }\n        .traffic-btn.max::after {\n            width: 4px;\n            height: 4px;\n            left: 50%;\n            top: 50%;\n            margin-left: -0.8px;\n            margin-top: -0.8px;\n            background: rgba(28, 86, 38, 0.96);\n            clip-path: polygon(100% 100%, 0 100%, 100% 0);\n            transform: none;\n        }\n        body.desktop-shell #window-controls:hover .traffic-btn::before,\n        body.desktop-shell #window-controls:hover .traffic-btn::after,\n        .traffic-btn:hover::before,\n        .traffic-btn:hover::after {\n            opacity: 1;\n        }\n        .traffic-btn:hover {\n            transform: translateY(-0.5px);\n            filter: saturate(1.03) brightness(1.02);\n            box-shadow: 0 0 0 1px rgba(0,0,0,0.28) inset, 0 0 0 0.5px rgba(255,255,255,0.18);\n        }\n        .traffic-btn:active {\n            transform: translateY(0);\n            filter: brightness(0.94);\n        }\n        body.electron-shell #window-controls {\n            display: flex !important;\n            gap: 7px;\n            top: calc(env(safe-area-inset-top, 0px) + 11px);\n            left: 13px;\n        }\n        body.electron-shell .traffic-btn {\n            width: 12px;\n            height: 12px;\n            border: 1px solid rgba(0, 0, 0, 0.28);\n            box-shadow:\n                0 1px 0 rgba(255, 255, 255, 0.28) inset,\n                0 0 0 1px rgba(0, 0, 0, 0.06);\n            transform: none;\n        }\n        body.electron-shell .traffic-btn.close {\n            background: radial-gradient(circle at 35% 30%, #ff8a82 0%, #ff5f57 68%);\n        }\n        body.electron-shell .traffic-btn.min {\n            background: radial-gradient(circle at 35% 30%, #ffd76a 0%, #febc2e 68%);\n        }\n        body.electron-shell .traffic-btn.max {\n            background: radial-gradient(circle at 35% 30%, #61e26f 0%, #28c840 68%);\n        }\n        body.electron-shell .traffic-btn.close::before,\n        body.electron-shell .traffic-btn.close::after {\n            background: rgba(77, 18, 17, 0.92);\n            width: 6.5px;\n            height: 1.35px;\n            margin-left: -3.25px;\n            margin-top: -0.67px;\n        }\n        body.electron-shell .traffic-btn.min::before {\n            background: rgba(96, 66, 9, 0.94);\n            width: 6.5px;\n            height: 1.35px;\n            margin-left: -3.25px;\n            margin-top: -0.67px;\n        }\n        body.electron-shell .traffic-btn.max::before {\n            background: rgba(16, 93, 31, 0.95);\n            width: 6.6px;\n            height: 1.35px;\n            margin-left: -3.3px;\n            margin-top: -0.67px;\n            clip-path: none;\n        }\n        body.electron-shell .traffic-btn.max::after {\n            background: rgba(16, 93, 31, 0.95);\n            width: 1.35px;\n            height: 6.6px;\n            margin-left: -0.67px;\n            margin-top: -3.3px;\n            clip-path: none;\n        }\n        body.electron-shell #window-controls .traffic-btn::before,\n        body.electron-shell #window-controls .traffic-btn::after {\n            opacity: 0;\n        }\n        body.electron-shell #window-controls:hover .traffic-btn::before,\n        body.electron-shell #window-controls:hover .traffic-btn::after {\n            opacity: 1;\n        }\n        body.electron-shell .traffic-btn:hover {\n            transform: none;\n            filter: saturate(1.02) brightness(1.015);\n        }\n        body.desktop-shell #lang-toggle-group {\n            top: calc(env(safe-area-inset-top, 0px) + 10px) !important;\n            left: auto !important;\n            right: 24px !important;\n            gap: 6px !important;\n        }\n        body.desktop-shell #lang-toggle-group button {\n            min-height: 28px;\n            padding: 6px 12px;\n            border-radius: 8px;\n            border: 1px solid rgba(255, 255, 255, 0.14);\n            background: rgba(0, 0, 0, 0.72);\n            color: #eee;\n            font-family: 'ArkPixel', monospace;\n            font-size: 12px;\n            line-height: 1.25;\n        }\n        body.desktop-shell #lang-toggle-group button.lang-active {\n            background: #141722;\n            color: rgb(246, 208, 6);\n            border-color: rgb(246, 208, 6);\n            box-shadow: 0 0 0 1px rgba(246, 208, 6, 0.45) inset;\n        }\n        body.desktop-shell #asset-drawer {\n            box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45) !important;\n        }\n        /* 底部面板容器 */\n        #main-stage {\n            position: relative;\n            width: 1280px;\n            transition: margin-left .25s ease;\n            will-change: margin-left;\n        }\n        body.drawer-open #main-stage {\n            margin-left: 0 !important;\n        }\n        #bottom-panels {\n            display: flex;\n            gap: 20px;\n            width: 1280px;\n            max-width: none;\n            justify-content: flex-start;\n            margin-top: 20px;\n        }\n        #game-container {\n            position: relative;\n            border: 0;\n            image-rendering: pixelated;\n            width: 1280px;\n            height: 720px;\n            max-width: none;\n            max-height: none;\n            aspect-ratio: auto;\n            overflow: hidden;\n        }\n        #game-container canvas {\n            width: 100% !important;\n            height: 100% !important;\n            image-rendering: pixelated;\n            /* 再兜底一次：即使外层高度变化，也不要拉伸变形 */\n            object-fit: contain;\n            /* 边框改为直接贴合画布内部，避免“框比地图大” */\n            box-shadow: inset 0 0 0 4px #64477d;\n            position: relative;\n            z-index: 10;\n        }\n        #loading-overlay {\n            position: fixed;\n            top: 0;\n            left: 0;\n            width: 100%;\n            height: 100%;\n            background: #1a1a2e;\n            display: flex;\n            flex-direction: column;\n            justify-content: center;\n            align-items: center;\n            z-index: 100000;\n        }\n        #loading-text {\n            color: #ffd700;\n            font-size: 18px;\n            margin-bottom: 20px;\n        }\n        #loading-progress-container {\n            width: 300px;\n            height: 20px;\n            background: #333;\n            border: 2px solid #555;\n            border-radius: 4px;\n        }\n        #loading-progress-bar {\n            height: 100%;\n            background: linear-gradient(90deg, #e94560, #ffd700);\n            width: 0%;\n            transition: width 0.3s ease;\n        }\n        #status-text {\n            position: absolute;\n            bottom: 12px;\n            left: 12px;\n            transform: none;\n            color: #eee;\n            font-size: 14px;\n            background: rgba(0,0,0,0.7);\n            padding: 8px 12px;\n            border-radius: 4px;\n            max-width: calc(100% - 24px);\n            text-align: left;\n            font-family: 'ArkPixel', 'Courier New', monospace;\n            z-index: 30;\n            pointer-events: none;\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n            line-height: 1.2;\n        }\n        #office-plaque-dom {\n            position: absolute;\n            left: 50%;\n            bottom: 14px;\n            transform: translateX(-50%);\n            display: inline-flex;\n            align-items: center;\n            gap: 14px;\n            padding: 6px 12px;\n            border: 2px solid #3e2723;\n            background: #5d4037;\n            color: #ffd700;\n            font-family: 'ArkPixel', 'Courier New', monospace;\n            font-size: 13px;\n            line-height: 1;\n            text-shadow: 0 1px 0 #000, 0 0 1px #000;\n            z-index: 35;\n            pointer-events: auto;\n            white-space: nowrap;\n            cursor: text;\n        }\n        #office-plaque-text {\n            font-size: 13px;\n            line-height: 1;\n            color: #ffd700;\n            outline: none;\n            min-width: 120px;\n            text-align: center;\n        }\n        #office-plaque-text.editing {\n            padding: 1px 4px;\n            background: rgba(0, 0, 0, 0.28);\n            box-shadow: 0 0 0 1px rgba(255, 215, 0, 0.45) inset;\n        }\n        .office-plaque-star {\n            font-size: 13px;\n            line-height: 1;\n        }\n        /* 状态控制栏 */\n        #control-bar {\n            position: relative;\n            background: #141722;\n            padding: 10px 10px 12px;\n            border-radius: 0;\n            border: 4px solid #0e1119;\n            box-shadow: none;\n            width: 390px;\n            height: 300px;\n            display: flex;\n            flex-direction: column;\n            gap: 10px;\n            overflow: hidden;\n        }\n        #control-bar::before {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);\n            background-repeat: no-repeat;\n            background-size:\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px),\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px);\n            background-position:\n                9px 8px, calc(50% + 5px) 8px,\n                8px 9px, calc(100% - 10px) 9px,\n                9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),\n                8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);\n        }\n        #control-bar::after {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);\n            background-repeat: no-repeat;\n            background-size:\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px;\n            background-position:\n                left top, left top,\n                right top, right top,\n                left bottom, left bottom,\n                right bottom, right bottom;\n        }\n        #control-bar-title {\n            color: #ffd700;\n            font-size: 16px;\n            font-weight: bold;\n            text-align: center;\n            letter-spacing: 1px;\n            padding: 6px 0 10px;\n            border-bottom: 0;\n        }\n        #control-buttons {\n            display: grid;\n            grid-template-columns: repeat(4, minmax(0, 1fr));\n            gap: 8px;\n            align-content: start;\n            padding-top: 4px;\n            padding-left: 10px;\n            padding-right: 10px;\n            box-sizing: border-box;\n        }\n        #btn-open-drawer {\n            grid-column: 1 / -1;\n            background: #78a340;\n            border-color: #8fbe4a;\n            color: #f3ffe6;\n            font-weight: 700;\n        }\n\n        #asset-drawer {\n            position: fixed;\n            top: 84px;\n            left: 50%;\n            right: auto;\n            width: 420px;\n            max-width: none;\n            height: 760px;\n            background: #111827;\n            border: 2px solid #22c55e;\n            border-radius: 10px;\n            box-shadow: 0 10px 30px rgba(0,0,0,0.45);\n            transform: translateX(-50%);\n            transition: opacity 0.18s ease;\n            z-index: 1000010;\n            display: none;\n            flex-direction: column;\n            opacity: 0;\n            pointer-events: none;\n        }\n        #asset-drawer.open {\n            display: flex;\n            opacity: 1;\n            pointer-events: auto;\n        }\n        #asset-drawer-header {\n            color: #ecfdf5;\n            font-size: 15px;\n            padding: 12px;\n            border-bottom: 1px solid #374151;\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n            background: #0b1220;\n            cursor: move;\n            user-select: none;\n            border-top-left-radius: 8px;\n            border-top-right-radius: 8px;\n        }\n        #asset-drawer-body {\n            padding: 10px;\n            padding-bottom: 150px;\n            overflow: auto;\n            color: #e5e7eb;\n            font-size: 12px;\n            position: relative;\n        }\n        #asset-drawer-backdrop {\n            position: fixed;\n            inset: 0;\n            background: rgba(0, 0, 0, 0.2);\n            z-index: 1000009;\n            display: none;\n        }\n        #asset-drawer-backdrop.open {\n            display: block;\n        }\n        .asset-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }\n        .asset-toolbar input { flex:1; min-width: 150px; padding:6px 8px; border-radius:6px; border:1px solid #374151; background:#1f2937; color:#fff; }\n        .asset-toolbar button, #asset-drawer-header button { cursor:pointer; border:1px solid #4b5563; background:#1f2937; color:#fff; border-radius:6px; padding:6px 8px; font-family:'ArkPixel', monospace; }\n        .asset-toolbar button:hover, #asset-drawer-header button:hover { border-color:#22c55e; }\n        #asset-list {\n            display:flex;\n            flex-direction:column;\n            gap:6px;\n            flex: 1 1 auto;\n            min-height: 120px;\n            max-height: 40vh;\n            overflow-y: auto;\n            padding-right: 2px;\n            scrollbar-color: #1f2937 #0b1220;\n            scrollbar-width: thin;\n        }\n        #asset-list::-webkit-scrollbar { width: 8px; }\n        #asset-list::-webkit-scrollbar-track { background: #0b1220; }\n        #asset-list::-webkit-scrollbar-thumb { background: #1f2937; border-radius: 0; border: 1px solid #111827; }\n        #asset-upload-panel {\n            position: sticky;\n            bottom: 8px;\n            margin-top: 10px;\n            background: #0b1220;\n            border: 1px solid #334155;\n            border-radius: 8px;\n            padding: 8px;\n            z-index: 50;\n            display: none;\n            box-shadow: 0 -4px 12px rgba(0,0,0,.35);\n        }\n        #asset-upload-panel.active {\n            display: block;\n        }\n        .asset-item {\n            border: 1px solid #374151;\n            background: #0f172a;\n            border-radius: 8px;\n            padding: 8px;\n            display: grid;\n            grid-template-columns: 56px 1fr 44px;\n            gap: 8px;\n            align-items: center;\n            cursor: pointer;\n        }\n        .asset-item.active { border-color: #22c55e; box-shadow: 0 0 0 1px #22c55e inset; }\n        .asset-vis-btn {\n            min-width: 34px;\n            height: 28px;\n            padding: 2px 4px;\n            border: 1px solid #4b5563;\n            background: #111827;\n            color: #d1d5db;\n            border-radius: 6px;\n            font-size: 14px;\n            cursor: pointer;\n            font-family:'ArkPixel', monospace;\n        }\n        .asset-vis-btn:hover { border-color:#22c55e; color:#ecfccb; }\n        .asset-thumb { width:56px; height:56px; object-fit: contain; background:#0b1220; border:1px solid #374151; border-radius:6px; }\n        .asset-meta { line-height: 1.45; }\n        .asset-path { color:#d1fae5; word-break: break-all; }\n        .asset-sub { color:#9ca3af; font-size:11px; }\n        #asset-upload-result { white-space: normal; line-height: 1.5; }\n        #asset-upload-result .hint-p { margin: 0 0 6px 0; }\n        #asset-upload-result .hint-p:last-child { margin-bottom: 0; }\n        .asset-plus-box { width:100%; height:92px; border:2px dashed #4b5563; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#9ca3af; font-size:34px; cursor:pointer; user-select:none; }\n        .asset-plus-box:hover { border-color:#22c55e; color:#22c55e; }\n        .asset-preview-box { border:1px solid #374151; border-radius:8px; padding:6px; background:#0b1220; margin-bottom:8px; }\n        .asset-preview-title { color:#9ca3af; font-size:11px; margin-bottom:4px; }\n        .asset-preview-img { width:100%; height:92px; object-fit:contain; background:#111827; border:1px solid #1f2937; border-radius:6px; }\n        .home-fav-list { display:flex; gap:8px; overflow-x:auto; padding-bottom:4px; }\n        .home-fav-item { min-width:126px; max-width:126px; border:1px solid #334155; border-radius:8px; background:#111827; padding:6px; }\n        .home-fav-item img { width:100%; height:70px; object-fit:cover; border:1px solid #1f2937; border-radius:6px; image-rendering:pixelated; }\n        .home-fav-meta { color:#9ca3af; font-size:10px; margin-top:4px; line-height:1.3; min-height:24px; }\n        .home-fav-item button { width:100%; margin-top:4px; border:1px solid #4b5563; background:#1f2937; color:#fff; border-radius:6px; padding:4px 6px; font-family:'ArkPixel', monospace; cursor:pointer; }\n        .home-fav-item button:hover { border-color:#22c55e; }\n        #gemini-api-doc-link { color:#86efac; text-decoration: underline; text-underline-offset: 2px; }\n        #gemini-api-doc-link:hover { color:#bbf7d0; }\n\n\n        #asset-move-panel { border:1px solid #334155; background:#0b1220; border-radius:10px; padding:10px; margin-bottom:10px; }\n        #asset-home-actions-panel { border:1px solid #334155; background:#0b1220; border-radius:10px; padding:10px; }\n        #asset-home-actions-panel .asset-toolbar { display:grid; grid-template-columns: 1fr 1fr; gap:8px; }\n        #asset-home-actions-panel .asset-toolbar > button { width:100%; margin:0; }\n        #asset-move-row { justify-content: center; gap:12px; margin-bottom:0; }\n        #asset-move-row .btn-move,\n        #asset-move-row .btn-home,\n        #asset-broker-row .btn-broker,\n        #asset-broker-row .btn-diy {\n            width:122px;\n            height:42px;\n            padding:8px 8px 0;\n            border:none;\n            border-radius:0;\n            background-color: transparent !important;\n            background-repeat:no-repeat;\n            background-size:300% 100%;\n            background-position:0 0;\n            image-rendering: pixelated;\n            appearance:none;\n            -webkit-appearance:none;\n            color:#fff;\n            text-align:center;\n            font-size:14px;\n            font-weight:400;\n            letter-spacing:.2px;\n            text-shadow:none;\n            display:inline-flex;\n            align-items:flex-start;\n            justify-content:center;\n            transition: padding-top .08s ease, filter .12s ease;\n            box-shadow:none;\n        }\n        #asset-move-row .btn-move { color:#1f2937; }\n        #asset-move-row .btn-move {\n            background-image:url('/static/btn-move-house-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-move-row .btn-home {\n            background-image:url('/static/btn-back-home-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-broker-row { justify-content:center; gap:12px; margin-top:8px; margin-bottom:0; }\n        #asset-broker-row .btn-broker {\n            background-image:url('/static/btn-broker-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-broker-row .btn-diy {\n            background-image:url('/static/btn-diy-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-manual-panel {\n            margin-top:0;\n            max-height:0;\n            opacity:0;\n            transform:translateY(-6px);\n            overflow:hidden;\n            pointer-events:none;\n            transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease;\n        }\n        #asset-manual-panel.open {\n            margin-top:8px;\n            max-height:1600px;\n            opacity:1;\n            transform:translateY(0);\n            pointer-events:auto;\n        }\n        #asset-broker-panel {\n            margin-top:0;\n            border:1px dashed #334155;\n            border-radius:8px;\n            padding:8px;\n            background:#0f172a;\n            max-height:0;\n            opacity:0;\n            transform:translateY(-6px);\n            overflow:hidden;\n            pointer-events:none;\n            transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease;\n        }\n        #asset-broker-panel.open {\n            margin-top:8px;\n            max-height:520px;\n            opacity:1;\n            transform:translateY(0);\n            pointer-events:auto;\n        }\n        #asset-broker-prompt {\n            width:100%; min-height:66px; resize:vertical;\n            padding:8px; border-radius:6px;\n            border:1px solid #334155; background:#111827; color:#e5e7eb;\n            font-family:'ArkPixel', monospace; font-size:12px;\n            box-sizing:border-box;\n        }\n        #asset-broker-actions { margin-top:8px; display:flex; justify-content:flex-end; }\n        #asset-broker-actions button {\n            background:#0ea5e9;\n            color:#e0f2fe;\n            border-color:#38bdf8;\n            font-weight:700;\n            font-size:12px;\n            padding:7px 10px;\n            min-width:112px;\n            text-align:center;\n            box-shadow: 0 2px 0 rgba(0,0,0,.25);\n            transition: transform .08s ease, filter .12s ease, box-shadow .12s ease;\n        }\n        #asset-move-row .btn-move:hover,\n        #asset-move-row .btn-home:hover,\n        #asset-broker-row .btn-broker:hover,\n        #asset-broker-row .btn-diy:hover,\n        #asset-broker-actions button:hover {\n            filter: brightness(1.06);\n            background-color: transparent !important;\n        }\n        #asset-move-row .btn-move:active,\n        #asset-move-row .btn-home:active,\n        #asset-broker-row .btn-broker:active,\n        #asset-broker-row .btn-diy:active,\n        #asset-broker-actions button:active,\n        #asset-move-row .btn-move.is-active,\n        #asset-move-row .btn-home.is-active,\n        #asset-broker-row .btn-broker.is-active,\n        #asset-broker-row .btn-diy.is-active,\n        #asset-broker-actions button.is-active {\n            padding-top:13px;\n            filter: brightness(0.96);\n            background-color: transparent !important;\n        }\n\n        #asset-move-row .btn-move:active,\n        #asset-move-row .btn-move.is-active,\n        #asset-move-row .btn-home:active,\n        #asset-move-row .btn-home.is-active,\n        #asset-broker-row .btn-broker:active,\n        #asset-broker-row .btn-broker.is-active,\n        #asset-broker-row .btn-diy:active,\n        #asset-broker-row .btn-diy.is-active {\n            background-position:50% 0;\n        }\n        #asset-move-row .btn-move.is-done,\n        #asset-move-row .btn-home.is-done,\n        #asset-broker-row .btn-broker.is-done,\n        #asset-broker-row .btn-diy.is-done {\n            background-position:100% 0;\n        }\n\n\n        #asset-highlight {\n            position: fixed;\n            border: 3px solid #22c55e;\n            background: transparent;\n            box-shadow: none;\n            pointer-events: none;\n            display: none;\n            z-index: 999998;\n        }\n        #room-loading-overlay {\n            position: fixed;\n            left: 0;\n            top: 0;\n            width: 0;\n            height: 0;\n            background: rgba(0, 0, 0, 0.62);\n            z-index: 1000000;\n            display: none;\n            align-items: center;\n            justify-content: center;\n            pointer-events: auto;\n            border-radius: 10px;\n        }\n        .room-loading-inner {\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            gap: 10px;\n            padding: 16px 20px;\n            border-radius: 10px;\n            border: 1px solid rgba(255,255,255,.2);\n            background: rgba(0,0,0,.36);\n            color: #fff;\n            font-family: 'ArkPixel', monospace;\n            font-size: 20px;\n            text-shadow: 0 2px 6px rgba(0,0,0,.45);\n        }\n        #room-loading-emoji {\n            font-size: 52px;\n            line-height: 1;\n            min-height: 56px;\n        }\n        #room-loading-text {\n            font-size: 20px;\n            letter-spacing: 1px;\n        }\n        #control-buttons button { height: 52px; }\n        #control-bar button {\n            background: #3a3f4f;\n            color: #fff;\n            border: 2px solid #555;\n            border-radius: 4px;\n            padding: 8px 10px;\n            cursor: pointer;\n            font-family: 'ArkPixel', monospace;\n            font-size: 12px;\n            transition: all 0.2s;\n        }\n        #control-bar button:hover {\n            background: #4a4f5f;\n            border-color: #e94560;\n        }\n        /* Star 状态四按钮（不含装修）使用像素精灵皮肤 */\n        #control-bar #btn-state-idle,\n        #control-bar #btn-state-writing,\n        #control-bar #btn-state-syncing,\n        #control-bar #btn-state-error {\n            background-image: url('/static/btn-state-sprite.png?v={{VERSION_TIMESTAMP}}');\n            background-color: transparent !important;\n            background-repeat: no-repeat;\n            background-size: 300% 100%;\n            background-position: 0 0;\n            border: none;\n            border-radius: 0;\n            appearance: none;\n            -webkit-appearance: none;\n            image-rendering: pixelated;\n            color: #5e6366;\n            font-weight: 400;\n            text-shadow: none;\n            padding: 0 8px 9px;\n            line-height: 1;\n            transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease;\n        }\n        #control-bar #btn-state-idle:hover,\n        #control-bar #btn-state-writing:hover,\n        #control-bar #btn-state-syncing:hover,\n        #control-bar #btn-state-error:hover {\n            background-color: transparent !important;\n            filter: brightness(1.04);\n        }\n        #control-bar #btn-state-idle:active,\n        #control-bar #btn-state-writing:active,\n        #control-bar #btn-state-syncing:active,\n        #control-bar #btn-state-error:active {\n            background-position: 50% 0;\n            padding-top: 5px;\n            padding-bottom: 0;\n            filter: brightness(0.97);\n        }\n        /* Desktop shell: keep 2x2 buttons centered and pressed-state offset visible */\n        body.desktop-shell #control-bar #btn-state-idle,\n        body.desktop-shell #control-bar #btn-state-writing,\n        body.desktop-shell #control-bar #btn-state-syncing,\n        body.desktop-shell #control-bar #btn-state-error {\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            text-align: center;\n            height: 40px !important;\n            padding: 0 8px 8px !important;\n            line-height: 1 !important;\n        }\n        body.desktop-shell #control-bar #btn-state-idle:active,\n        body.desktop-shell #control-bar #btn-state-writing:active,\n        body.desktop-shell #control-bar #btn-state-syncing:active,\n        body.desktop-shell #control-bar #btn-state-error:active {\n            padding-top: 5px !important;\n            padding-bottom: 3px !important;\n        }\n        /* 装修房间按钮使用像素精灵皮肤 */\n        #control-bar #btn-open-drawer {\n            background-image: url('/static/btn-open-drawer-sprite.png?v={{VERSION_TIMESTAMP}}') !important;\n            background-color: transparent !important;\n            background-repeat: no-repeat !important;\n            background-size: 300% 100% !important;\n            background-position: 0 0 !important;\n            border: none !important;\n            border-radius: 0 !important;\n            appearance: none;\n            -webkit-appearance: none;\n            image-rendering: pixelated;\n            color: #5e6366 !important;\n            font-weight: 400 !important;\n            font-size: 15px !important;\n            text-shadow: none !important;\n            padding: 0 10px 10px !important;\n            line-height: 1 !important;\n            transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease;\n        }\n        #control-bar #btn-open-drawer:hover {\n            background-color: transparent !important;\n            filter: brightness(1.04);\n        }\n        #control-bar #btn-open-drawer:active {\n            background-position: 50% 0 !important;\n            padding-top: 5px !important;\n            padding-bottom: 5px !important;\n            filter: brightness(0.97);\n        }\n        /* Guest Agent 名单面板（右下角） */\n        #guest-agent-panel {\n            position: relative;\n            width: 390px;\n            height: 300px;\n            background: #141722;\n            padding: 10px 10px 12px;\n            border-radius: 0;\n            border: 4px solid #0e1119;\n            box-shadow: none;\n            display: flex;\n            flex-direction: column;\n            gap: 10px;\n            overflow: hidden;\n        }\n        #guest-agent-panel::before {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);\n            background-repeat: no-repeat;\n            background-size:\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px),\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px);\n            background-position:\n                9px 8px, calc(50% + 5px) 8px,\n                8px 9px, calc(100% - 10px) 9px,\n                9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),\n                8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);\n        }\n        #guest-agent-panel::after {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);\n            background-repeat: no-repeat;\n            background-size:\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px;\n            background-position:\n                left top, left top,\n                right top, right top,\n                left bottom, left bottom,\n                right bottom, right bottom;\n        }\n        #guest-agent-panel-title {\n            color: #ffd700;\n            font-size: 16px;\n            font-weight: bold;\n            text-align: center;\n            letter-spacing: 1px;\n            padding: 6px 0 10px;\n            border-bottom: 0;\n            margin-bottom: 0;\n        }\n        #guest-agent-list {\n            flex-grow: 1;\n            overflow-y: auto;\n            display: flex;\n            flex-direction: column;\n            gap: 8px;\n            padding-right: 4px;\n        }\n        #guest-agent-list::-webkit-scrollbar { width: 6px; }\n        #guest-agent-list::-webkit-scrollbar-track { background: #1a1a2e; }\n        #guest-agent-list::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }\n        .guest-agent-item {\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            gap: 8px;\n            background: #3a3f4f;\n            padding: 8px 10px;\n            border-radius: 6px;\n            border: 1px solid #555;\n        }\n        .guest-agent-name {\n            color: #fff;\n            font-size: 14px;\n            flex-shrink: 0;\n        }\n        .guest-agent-buttons {\n            display: flex;\n            gap: 6px;\n            flex-shrink: 0;\n        }\n        .guest-agent-buttons button {\n            padding: 6px 10px;\n            border-radius: 4px;\n            border: 2px solid #555;\n            background: #4a4f5f;\n            color: #fff;\n            font-family: 'ArkPixel', monospace;\n            font-size: 12px;\n            cursor: pointer;\n            transition: all 0.2s;\n        }\n        .guest-agent-buttons button:hover {\n            background: #5a5f6f;\n            border-color: #e94560;\n        }\n        .guest-agent-buttons button.leave-btn {\n            background: #5a1818;\n            border-color: #e94560;\n        }\n        .guest-agent-buttons button.leave-btn:hover {\n            background: #6a2828;\n        }\n        /* Memo 区域 - 4:3 小正方形 */\n        #memo-panel {\n            position: relative;\n            width: 460px;\n            height: 300px;\n            background-image: url('/static/memo-bg.webp');\n            background-size: cover;\n            background-position: center;\n            border: 4px solid #0e1119;\n            border-radius: 0;\n            padding: 14px 16px;\n            box-shadow: none;\n            display: flex;\n            flex-direction: column;\n            overflow: hidden;\n        }\n        #memo-panel::before {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);\n            background-repeat: no-repeat;\n            background-size:\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px),\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px);\n            background-position:\n                9px 8px, calc(50% + 5px) 8px,\n                8px 9px, calc(100% - 10px) 9px,\n                9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),\n                8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);\n        }\n        #memo-panel::after {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc),\n                linear-gradient(#8fa0bc, #8fa0bc), linear-gradient(#8fa0bc, #8fa0bc);\n            background-repeat: no-repeat;\n            background-size:\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px;\n            background-position:\n                left top, left top,\n                right top, right top,\n                left bottom, left bottom,\n                right bottom, right bottom;\n        }\n        #memo-panel.no-bg {\n            background-image: none !important;\n            background-color: #111827;\n        }\n        #memo-title {\n            color: #1b192e;\n            font-size: 16px;\n            font-weight: bold;\n            margin-bottom: 6px;\n            text-align: center;\n            letter-spacing: 1px;\n            flex-shrink: 0;\n            position: relative;\n            top: 15px;\n        }\n        #memo-date {\n            color: #888;\n            font-size: 10px;\n            margin-bottom: 8px;\n            text-align: right;\n            flex-shrink: 0;\n            position: relative;\n            left: -40px; /* move date left by 40px */\n            top: -10px;\n        }\n        #memo-content {\n            color: #3b3b32;\n            font-size: 12px;\n            line-height: 1.8;\n            white-space: pre-wrap;\n            word-wrap: break-word;\n            overflow-y: auto;\n            flex-grow: 1;\n            padding-right: 4px;\n            position: relative;\n            left: 100px; /* move content right by 100px */\n            top: -10px;\n        }\n        #memo-content::-webkit-scrollbar {\n            width: 6px;\n        }\n        #memo-content::-webkit-scrollbar-track {\n            background: #1a1a2e;\n        }\n        #memo-content::-webkit-scrollbar-thumb {\n            background: #444;\n            border-radius: 3px;\n        }\n        #memo-placeholder {\n            color: #666;\n            font-style: italic;\n            text-align: center;\n            padding: 20px 0;\n        }\n        .memo-decoration {\n            text-align: center;\n            margin: 4px 0;\n            color: #555;\n            font-size: 10px;\n            flex-shrink: 0;\n        }\n        .panel-collapsible {\n            transition: height 0.2s ease, min-height 0.2s ease;\n        }\n        .panel-collapsible.collapsed {\n            height: 48px !important;\n            min-height: 48px !important;\n            overflow: hidden;\n        }\n        .panel-collapsible.collapsed > :not(.panel-toggle-title) {\n            display: none !important;\n        }\n        body.desktop-shell .panel-collapsible {\n            border-radius: 0;\n            border: none !important;\n            background: transparent !important;\n            box-shadow: none !important;\n        }\n        body.desktop-shell .panel-toggle-title {\n            height: 44px;\n            min-height: 44px;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            background: #0B1117;\n            color: rgb(246, 208, 6) !important;\n            border-radius: 0;\n            border: 2px solid #6f84a2;\n            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22);\n            margin: 0;\n            padding: 0 12px;\n            line-height: 1;\n            font-size: 13px;\n            letter-spacing: 0.5px;\n            text-shadow: none;\n            transition: transform 0.15s ease, filter 0.15s ease, box-shadow 0.15s ease;\n        }\n        body.desktop-shell .panel-toggle-title:hover {\n            background: #0B1117;\n            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.28);\n            transform: translateY(-1px);\n        }\n        body.desktop-shell .panel-toggle-title:active {\n            transform: translateY(0);\n        }\n        body.desktop-shell #memo-title,\n        body.desktop-shell #control-bar-title,\n        body.desktop-shell #guest-agent-panel-title {\n            display: flex !important;\n            align-items: center !important;\n            justify-content: center !important;\n            height: 44px !important;\n            min-height: 44px !important;\n            margin: 0 !important;\n            padding: 0 12px !important;\n            border: 2px solid #6f84a2 !important;\n            border-bottom: 2px solid #6f84a2 !important;\n            border-radius: 0 !important;\n            background: #0B1117 !important;\n            color: rgb(246, 208, 6) !important;\n            font-size: 13px !important;\n            font-weight: 400 !important;\n            line-height: 1 !important;\n            letter-spacing: 0.5px !important;\n            text-shadow: none !important;\n            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.22) !important;\n            position: relative !important;\n            top: 0 !important;\n            left: 0 !important;\n        }\n        body.desktop-shell .panel-collapsible.collapsed {\n            pointer-events: auto;\n            padding: 6px 8px !important;\n        }\n        body.desktop-shell .panel-collapsible.collapsed .panel-toggle-title {\n            pointer-events: auto;\n            margin: 0;\n        }\n        body.desktop-shell #memo-panel.collapsed::before,\n        body.desktop-shell #memo-panel.collapsed::after,\n        body.desktop-shell #control-bar.collapsed::before,\n        body.desktop-shell #control-bar.collapsed::after,\n        body.desktop-shell #guest-agent-panel.collapsed::before,\n        body.desktop-shell #guest-agent-panel.collapsed::after {\n            display: none !important;\n        }\n        /* memo 展开时只保留底图，不叠加线条装饰 */\n        body.desktop-shell #memo-panel:not(.collapsed)::before,\n        body.desktop-shell #memo-panel:not(.collapsed)::after {\n            display: none !important;\n        }\n        body.desktop-shell .panel-collapsible:not(.collapsed) {\n            padding: 6px 8px 8px !important;\n        }\n        body.desktop-shell #memo-panel:not(.collapsed) {\n            background-image: url('/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}') !important;\n            background-size: contain !important;\n            /* 下移背景图，让主体落在折叠/展开标题条下方 */\n            background-position: center 52px !important;\n            background-repeat: no-repeat !important;\n            min-height: 240px !important;\n            height: 240px !important;\n        }\n        body.desktop-shell #control-bar:not(.collapsed),\n        body.desktop-shell #guest-agent-panel:not(.collapsed) {\n            background: transparent !important;\n            border: none !important;\n            border-radius: 0 !important;\n            box-shadow: none !important;\n        }\n        body.desktop-shell #guest-agent-list::-webkit-scrollbar-track {\n            background: rgba(250, 244, 207, 0.65);\n        }\n        body.desktop-shell #guest-agent-list::-webkit-scrollbar-thumb {\n            background: rgba(93, 64, 55, 0.55);\n        }\n        body.desktop-shell .guest-agent-item {\n            background: rgba(250, 244, 207, 0.9) !important;\n            border: 1px solid #edd690 !important;\n        }\n        body.desktop-shell .guest-agent-name,\n        body.desktop-shell #guest-agent-panel [style*=\"color:#cbd5e1\"] {\n            color: #5d4037 !important;\n        }\n        body.desktop-shell #control-bar button,\n        body.desktop-shell .guest-agent-buttons button {\n            border: 1px solid #edd690;\n            background: rgba(250, 244, 207, 0.92);\n            color: #5d4037 !important;\n            user-select: none;\n            -webkit-user-select: none;\n        }\n        body.desktop-shell #control-bar button:hover,\n        body.desktop-shell .guest-agent-buttons button:hover {\n            border-color: #faf4cf;\n            background: rgba(237, 214, 144, 0.96);\n        }\n        .panel-toggle-title {\n            cursor: pointer;\n            user-select: none;\n        }\n\n        /* 手机端专属适配（不影响桌面） */\n        @media (max-width: 900px), (pointer: coarse) {\n            html, body {\n                height: 100%;\n            }\n\n            body {\n                padding: 0;\n                gap: 0;\n                overflow-x: auto;\n                overflow-y: auto;\n                -webkit-overflow-scrolling: touch;\n                align-items: stretch;\n            }\n\n            #game-container {\n                width: 100vw;\n                height: 66.666vh; /* 办公室占 2/3 屏幕高度 */\n                max-width: 100vw;\n                max-height: 66.666vh;\n                border-width: 0;\n                border-radius: 0;\n                aspect-ratio: auto;\n                flex: 0 0 auto;\n                touch-action: auto;\n                overflow: hidden;\n            }\n\n            #main-stage {\n                width: 100vw;\n                min-width: 0;\n                margin-left: 0 !important;\n            }\n\n            #bottom-panels {\n                width: 100vw;\n                max-width: 100vw;\n                min-height: 33.334vh; /* 余下约 1/3 可见区 */\n                padding: 10px 10px 16px;\n                display: flex;\n                flex-direction: column;\n                gap: 10px;\n                flex: 0 0 auto;\n            }\n\n            body.drawer-open #main-stage {\n                margin-left: 0 !important;\n            }\n\n            #memo-panel,\n            #control-bar,\n            #guest-agent-panel {\n                width: 100%;\n                height: auto;\n                min-height: 180px;\n            }\n\n            #memo-panel { min-height: 220px; }\n            #control-bar { min-height: 210px; }\n            #guest-agent-panel { min-height: 220px; }\n\n            #memo-date {\n                left: 0;\n                text-align: left;\n                margin-bottom: 6px;\n            }\n            #memo-content {\n                left: 0;\n                font-size: 13px;\n                line-height: 1.7;\n            }\n\n            #control-bar-title,\n            #guest-agent-panel-title,\n            #memo-title {\n                font-size: 14px;\n            }\n\n            #control-buttons button,\n            #control-bar button,\n            .guest-agent-buttons button {\n                font-size: 12px;\n                min-height: 44px;\n            }\n            #control-buttons {\n                grid-template-columns: repeat(4, minmax(0, 1fr));\n                gap: 6px;\n            }\n            #control-buttons button {\n                min-height: 40px;\n                padding: 4px 2px;\n                font-size: 11px;\n            }\n\n            .guest-agent-item {\n                align-items: flex-start;\n                gap: 10px;\n                flex-direction: column;\n            }\n            .guest-agent-buttons {\n                width: 100%;\n                display: grid;\n                grid-template-columns: 1fr 1fr;\n                gap: 8px;\n            }\n\n            #status-text {\n                bottom: 8px;\n                left: 8px;\n                max-width: 64vw;\n                font-size: 12px;\n                padding: 8px 12px;\n            }\n\n            #coords-toggle,\n            #pan-toggle,\n            #lang-btn-en,\n            #lang-btn-jp,\n            #lang-btn-cn {\n                font-size: 12px !important;\n                padding: 6px 8px !important;\n            }\n\n            #asset-drawer {\n                width: 92vw;\n                max-width: 92vw;\n                max-height: 84vh;\n            }\n            #asset-drawer-body { padding: 8px; }\n            #asset-list {\n                display: flex;\n                flex-direction: column;\n                gap: 6px;\n            }\n            .asset-item {\n                grid-template-columns: 52px 1fr 36px;\n                padding: 6px;\n                gap: 6px;\n            }\n            .asset-thumb { width:52px; height:52px; }\n            .asset-path { font-size: 11px; line-height: 1.3; }\n            .asset-sub { font-size: 10px; }\n            #asset-upload-panel {\n                position: sticky;\n                bottom: 0;\n                padding: 8px;\n            }\n            #asset-upload-panel .asset-toolbar {\n                gap: 6px;\n                margin-bottom: 6px;\n            }\n            #asset-upload-panel input {\n                min-width: 0;\n                flex: 1 1 42%;\n            }\n            #asset-upload-panel button {\n                min-height: 38px;\n            }\n        }\n    </style>\n</head>\n<body>\n    <div id=\"window-controls\">\n        <button id=\"btn-close\" class=\"traffic-btn close\" title=\"关闭\"></button>\n        <button id=\"btn-minimize-mode\" class=\"traffic-btn min\" title=\"最小化模式\"></button>\n        <button id=\"btn-open-frontend\" class=\"traffic-btn max\" title=\"回到前端界面\"></button>\n    </div>\n    <div id=\"status-fab\">加载中...</div>\n    <!-- 加载遮罩 -->\n    <div id=\"loading-overlay\">\n        <div id=\"loading-text\">Loading Star’s pixel office...</div>\n        <div id=\"loading-progress-container\">\n            <div id=\"loading-progress-bar\"></div>\n        </div>\n    </div>\n    \n    <div id=\"main-stage\">\n        <div id=\"game-container\">\n            <div id=\"status-text\">加载中...</div>\n            <div id=\"office-plaque-dom\">\n                <span class=\"office-plaque-star\">⭐</span>\n                <span id=\"office-plaque-text\">海辛小龙虾的办公室</span>\n                <span class=\"office-plaque-star\">⭐</span>\n            </div>\n        </div>\n    \n    <!-- 底部面板容器 -->\n    <div id=\"bottom-panels\">\n        <!-- Memo 面板 -->\n        <div id=\"memo-panel\">\n            <div id=\"memo-title\">昨 日 小 记</div>\n            <div id=\"memo-date\"></div>\n            <div class=\"memo-decoration\">─ ─ ─ ─ ─</div>\n            <div id=\"memo-content\">\n                <div id=\"memo-placeholder\">加载中...</div>\n            </div>\n            <div class=\"memo-decoration\">─ ─ ─ ─ ─</div>\n        </div>\n        \n        <!-- 状态控制栏 -->\n        <div id=\"control-bar\">\n            <div id=\"control-bar-title\">Star 状态</div>\n            <div id=\"control-buttons\">\n                <button id=\"btn-state-idle\" onclick=\"setState('idle', getStateDetailByState('idle'))\">待命</button>\n                <button id=\"btn-state-writing\" onclick=\"setState('writing', getStateDetailByState('writing'))\">工作</button>\n                <button id=\"btn-state-syncing\" onclick=\"setState('syncing', getStateDetailByState('syncing'))\">同步</button>\n                <button id=\"btn-state-error\" onclick=\"setState('error', getStateDetailByState('error'))\">报警</button>\n                <button id=\"btn-open-drawer\" onclick=\"openAssetWindowFromMain()\">装修房间</button>\n            </div>\n        </div>\n\n        <!-- Guest Agent 名单面板（右下角） -->\n        <div id=\"guest-agent-panel\">\n            <div id=\"guest-agent-panel-title\">访 客 列 表</div>\n            <div id=\"guest-agent-list\">\n                <div style=\"color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;\">正在加载访客...</div>\n            </div>\n            </div>\n        </div>\n    </div>\n    \n    <div id=\"asset-highlight\"></div>\n    <div id=\"room-loading-overlay\" aria-live=\"polite\" aria-busy=\"true\">\n        <div class=\"room-loading-inner\">\n            <div id=\"room-loading-emoji\">🦞</div>\n            <div id=\"room-loading-text\">正在打包虾头……</div>\n        </div>\n    </div>\n\n    <div id=\"asset-drawer-backdrop\" onclick=\"toggleAssetDrawer(false)\"></div>\n    <aside id=\"asset-drawer\" data-no-window-drag=\"1\">\n        <div id=\"asset-drawer-header\" data-no-window-drag=\"1\">\n            <span>装修房间 · 资产侧边栏</span>\n            <button id=\"btn-close-drawer\" onclick=\"toggleAssetDrawer(false)\">关闭</button>\n        </div>\n        <div id=\"asset-drawer-body\">\n            <div id=\"asset-auth-gate\" class=\"asset-preview-box\">\n                <div class=\"asset-preview-title\">请输入装修验证码</div>\n                <div class=\"asset-toolbar\">\n                    <input id=\"asset-pass-input\" type=\"password\" placeholder=\"输入验证码\" />\n                    <button onclick=\"unlockAssetDrawer()\">验证</button>\n                </div>\n                <div id=\"asset-auth-msg\" class=\"asset-sub\"></div>\n            </div>\n\n            <div id=\"asset-main-content\" style=\"display:none;\">\n                <div id=\"asset-move-panel\">\n                    <div class=\"asset-toolbar\" id=\"asset-move-row\">\n                        <button id=\"btn-move-house\" class=\"btn-move\" onclick=\"generateRpgBackground()\">📦 搬新家</button>\n                        <button id=\"btn-back-home\" class=\"btn-home\" onclick=\"restoreHomeBackground()\">🐚 回老家</button>\n                    </div>\n                    <div class=\"asset-toolbar\" id=\"asset-broker-row\">\n                        <button class=\"btn-broker\" onclick=\"toggleBrokerPanel()\">🤝 找中介</button>\n                        <button id=\"btn-diy\" class=\"btn-diy\" onclick=\"toggleManualPanel()\">🪚 自己装</button>\n                    </div>\n                    <div id=\"asset-move-result\" class=\"asset-sub\" style=\"margin-top:4px; margin-bottom:6px;\"></div>\n                    <div id=\"asset-broker-panel\">\n                        <div class=\"asset-sub\" style=\"margin-bottom:6px;\">写你的风格主题（严格保持原始房间结构，只改变视觉风格）</div>\n                        <textarea id=\"asset-broker-prompt\" placeholder=\"例如：像素风赛博东京夜景，霓虹灯、雨夜地面反光、蓝紫主色\"></textarea>\n                        <div class=\"asset-toolbar\" style=\"margin-top:6px; gap:8px; align-items:center; justify-content:flex-start;\">\n                            <span id=\"speed-mode-label\" class=\"asset-sub\" style=\"min-width:62px;\">生成模式</span>\n                            <button id=\"speed-fast-btn\" type=\"button\" onclick=\"setSpeedMode('fast')\" style=\"background:#22c55e;color:#052e16;border-color:#16a34a;\">🍌2</button>\n                            <button id=\"speed-quality-btn\" type=\"button\" onclick=\"setSpeedMode('quality')\" style=\"background:#334155;color:#e5e7eb;border-color:#475569;\">🍌Pro</button>\n                        </div>\n                        <details id=\"asset-gemini-panel\" style=\"margin-top:6px; border:1px dashed #334155; border-radius:8px; padding:8px; background:#0b1220;\">\n                            <summary id=\"gemini-panel-summary\" style=\"cursor:pointer; color:#cbd5e1;\">🔐 API 设置（可折叠）</summary>\n                            <div id=\"asset-gemini-config\" style=\"display:block; margin-top:6px;\">\n                                <div id=\"gemini-config-hint\" class=\"asset-sub\" style=\"margin-bottom:4px;\">可选：填写你的生图 API Key（留空不影响基础功能）</div>\n                                <div class=\"asset-sub\" style=\"margin-bottom:6px;\"><a id=\"gemini-api-doc-link\" href=\"https://ai.google.dev/gemini-api/docs/api-key?hl=zh-cn\" target=\"_blank\" rel=\"noopener noreferrer\">📘 如何申请 Google API Key</a></div>\n                                <div id=\"gemini-mask-status\" class=\"asset-sub\" style=\"margin-bottom:6px; color:#a7f3d0;\"></div>\n                                <div class=\"asset-toolbar\" style=\"gap:6px; flex-wrap:wrap;\">\n                                    <input id=\"gemini-api-key-input\" type=\"password\" placeholder=\"粘贴 GEMINI_API_KEY（不会回显）\" style=\"min-width:220px; flex:1;\" autocomplete=\"new-password\" />\n                                    <button id=\"btn-save-gemini-key\" onclick=\"saveGeminiConfigFromUI()\">保存 Key</button>\n                                </div>\n                                <div id=\"gemini-config-msg\" class=\"asset-sub\" style=\"margin-top:4px;\"></div>\n                            </div>\n                        </details>\n                        <div id=\"asset-broker-actions\">\n                            <button onclick=\"generateCustomRpgBackground()\">按中介方案搬家</button>\n                        </div>\n                    </div>\n                </div>\n\n                <div id=\"asset-home-actions-panel\" class=\"asset-preview-box\" style=\"margin-bottom:10px;\">\n                    <div class=\"asset-toolbar\" style=\"margin-bottom:6px; gap:8px;\">\n                        <button id=\"btn-back-last-bg\" class=\"btn-home\" onclick=\"restoreLastGeneratedBackground()\">↩️ 回上一个家</button>\n                        <button id=\"btn-favorite-home\" class=\"btn-home\" onclick=\"saveCurrentHomeFavorite()\">⭐ 收藏这个家</button>\n                    </div>\n                    <div id=\"asset-home-favorites\" class=\"asset-preview-box\" style=\"margin:0;\">\n                        <div id=\"asset-home-favorites-title\" class=\"asset-preview-title\">🏠 收藏的家</div>\n                        <div id=\"asset-home-favorites-list\" class=\"home-fav-list\"></div>\n                    </div>\n                </div>\n\n                <div id=\"asset-manual-panel\">\n                    <div class=\"asset-toolbar\">\n                        <input id=\"asset-search\" placeholder=\"搜索资产名（如 desk / sofa / star）\" oninput=\"renderAssetDrawerList()\" />\n                    </div>\n                    <div id=\"asset-list\"></div>\n                    <div id=\"asset-upload-panel\">\n                        <input id=\"asset-upload-file\" type=\"file\" accept=\"image/*\" style=\"display:none;\" />\n                        <div class=\"asset-toolbar\" style=\"margin-top:0; margin-bottom:6px; gap:8px;\">\n                            <button id=\"asset-choose-btn\" onclick=\"openInlineAssetUploader()\">上传替换素材</button>\n                            <button id=\"asset-commit-refresh-btn\" onclick=\"commitAndRefresh()\" disabled style=\"opacity:.55;\">确认并刷新</button>\n                        </div>\n                        <div class=\"asset-toolbar\" style=\"margin-top:0; margin-bottom:6px; gap:8px;\">\n                            <button id=\"asset-reset-default-btn\" onclick=\"resetSelectedAssetToDefault()\" disabled style=\"opacity:.55;\">重置为默认资产</button>\n                            <button id=\"asset-restore-prev-btn\" onclick=\"restoreSelectedAssetPrev()\" disabled style=\"opacity:.55;\">用上一版</button>\n                        </div>\n                        <div id=\"asset-upload-result\" class=\"asset-sub\"></div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </aside>\n    <div id=\"coords-overlay\" style=\"display:none; position:fixed; pointer-events:none; background:rgba(0,0,0,0.85); color:#fff; font-family:ArkPixel,monospace; font-size:14px; padding:8px 12px; border-radius:4px; z-index:99999;\">\n        <div id=\"coords-display\">X: 0 | Y: 0</div>\n    </div>\n    <button id=\"coords-toggle\" style=\"position:fixed; top:calc(env(safe-area-inset-top, 0px) + 12px); right:12px; z-index:999999; padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;\">\n        显示坐标\n    </button>\n    <button id=\"pan-toggle\" style=\"position:fixed; top:calc(env(safe-area-inset-top, 0px) + 12px); left:12px; z-index:999999; padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;\">\n        移动视野\n    </button>\n    <div id=\"lang-toggle-group\" style=\"position:fixed; top:calc(env(safe-area-inset-top, 0px) + 52px); left:12px; z-index:999999; display:flex; align-items:center;\">\n        <button id=\"lang-btn-en\" onclick=\"setUILanguage('en')\" style=\"cursor:pointer;\">EN</button>\n        <button id=\"lang-btn-jp\" onclick=\"setUILanguage('ja')\" style=\"cursor:pointer;\">JP</button>\n        <button id=\"lang-btn-cn\" onclick=\"setUILanguage('zh')\" style=\"cursor:pointer;\">CN</button>\n    </div>\n\n    <script src=\"/static/vendor/phaser-3.80.1.min.js\"></script>\n    <script>\n        // 简易中英文切换\n        let uiLang = localStorage.getItem('uiLang') || 'en';\n        const OFFICE_PLAQUE_STORAGE_KEY = 'officePlaqueTitle';\n        let officePlaqueCustomTitle = (localStorage.getItem(OFFICE_PLAQUE_STORAGE_KEY) || '').trim();\n        const I18N = {\n            zh: {\n                controlTitle: 'Star 状态',\n                btnIdle: '待命', btnWork: '工作', btnSync: '同步', btnError: '报警', btnDecor: '装修房间',\n                drawerTitle: '装修房间 · 资产侧边栏', drawerClose: '关闭',\n                authTitle: '请输入装修验证码', authPlaceholder: '输入验证码', authVerify: '验证', authDefaultPassHint: '默认密码：1234（可随时让我帮你改，建议改成强密码）',\n                drawerVisibilityTip: '可见性：点击条目右侧眼睛按钮切换该资产显示',\n                hideDrawer: '👁 隐藏侧边栏', showDrawer: '👁 显示侧边栏',\n                assetHide: '隐藏', assetShow: '显示',\n                resetToDefault: '重置为默认资产', restorePrevAsset: '用上一版',\n                btnMove: '📦 搬新家', btnHome: '🐚 回老家', btnHomeLast: '↩️ 回上一个家', btnHomeFavorite: '⭐ 收藏这个家', btnBroker: '🤝 找中介', btnDIY: '🪚 自己装', btnBrokerGo: '听中介的',\n                homeFavTitle: '🏠 收藏的家', homeFavEmpty: '还没有收藏，先点“⭐ 收藏这个家”', homeFavApply: '替换到当前地图', homeFavSaved: '✅ 已收藏当前地图', homeFavApplied: '✅ 已替换为收藏地图',\n                brokerHint: '你会给龙虾推荐什么样的房子',\n                brokerPromptPh: '例如：故宫主题、莫奈风格、地牢主题、兵马俑主题……',\n                brokerNeedPrompt: '请先输入中介方案描述',\n                brokerGenerating: '🏘️ 正在按中介方案生成底图，请稍候（约20-90秒）...',\n                brokerDone: '✅ 已按中介方案生成并替换底图，正在刷新房间...',\n                moveSuccess: '✅ 搬家成功！',\n                brokerMissingKey: '❌ 生图失败：缺少 GEMINI API Key，请在下方填写并保存后重试',\n                geminiPanelTitle: '🔐 API 设置（可折叠）', geminiHint: '可选：填写你的生图 API Key（留空不影响基础功能）', geminiApiDoc: '📘 如何申请 Google API Key', geminiInputPh: '粘贴 GEMINI_API_KEY（不会回显）', geminiSaveKey: '保存 Key', geminiMaskNoKey: '当前状态：未配置 Key', geminiMaskHasKey: '当前已配置：',\n                speedModeLabel: '生成模式', speedFast: '🍌2', speedQuality: '🍌Pro',\n                searchPlaceholder: '搜索资产名（如 desk / sofa / star）', loaded: '已加载', allAssets: '全部资产',\n                chooseImage: '上传替换素材', confirmUpload: '确认并刷新', uploadPending: '待上传', uploadTarget: '目标',\n                assetHintNotInScene: '当前场景未检测到此对象，仍可替换文件（刷新后生效）',\n                assetHintDefault: '通用素材：建议保持原图尺寸、透明通道与视觉重心一致，避免错位或失真',\n                showCoords: '显示坐标', hideCoords: '隐藏坐标', moveView: '移动视野', lockView: '锁定视野',\n                memoTitle: '昨 日 小 记', guestTitle: '访 客 列 表', officeTitle: '海辛小龙虾的办公室',\n                loadingOffice: '正在加载 Star 的像素办公室...',\n                panelExpand: '展开', panelCollapse: '收起',\n                hiddenTag: '已隐藏', assetListLoaded: '已加载资产', sceneCaptured: '场景抓取', assetListLoadFailed: '资产加载失败，请点“刷新”重试',\n                authNeedInput: '请输入验证码', authPassOk: '验证通过', authPassWrong: '验证码错误',\n                stateDetailIdle: '待命', stateDetailWriting: '整理文档', stateDetailResearching: '搜索信息', stateDetailExecuting: '执行任务', stateDetailSyncing: '同步备份', stateDetailError: '出错了',\n                stateLabelIdle: '待命', stateLabelWriting: '整理文档', stateLabelResearching: '搜索信息', stateLabelExecuting: '执行任务', stateLabelSyncing: '同步备份', stateLabelError: '出错了',\n                statusBrokerDecorating: '正在处理中介装修方案', statusMovingHome: '正在搬新家', statusRestoreHome: '正在回老家', statusRestoreLastBg: '正在回退到上一次背景', statusApplyFavorite: '正在替换收藏地图'\n            },\n            en: {\n                controlTitle: 'Star Status',\n                btnIdle: 'Idle', btnWork: 'Work', btnSync: 'Sync', btnError: 'Alert', btnDecor: 'Decorate Room',\n                drawerTitle: 'Decorate Room · Asset Sidebar', drawerClose: 'Close',\n                authTitle: 'Enter Decor Passcode', authPlaceholder: 'Enter passcode', authVerify: 'Verify', authDefaultPassHint: 'Default passcode: 1234 (ask me anytime to change it; stronger passcode recommended)',\n                drawerVisibilityTip: 'Visibility: use the eye button on each row to hide/show that asset',\n                hideDrawer: '👁 Hide Drawer', showDrawer: '👁 Show Drawer',\n                assetHide: 'Hide', assetShow: 'Show',\n                resetToDefault: 'Reset to Default', restorePrevAsset: 'Use Previous',\n                btnMove: '📦 New Home', btnHome: '🐚 Go Home', btnHomeLast: '↩️ Last One', btnHomeFavorite: '⭐ Save This Home', btnBroker: '🤝 Broker', btnDIY: '🪚 DIY', btnBrokerGo: 'Follow Broker',\n                homeFavTitle: '🏠 Saved Homes', homeFavEmpty: 'No saved homes yet. Tap “⭐ Save This Home” first.', homeFavApply: 'Apply to Current Map', homeFavSaved: '✅ Current map saved', homeFavApplied: '✅ Applied saved home',\n                brokerHint: 'What kind of house would you recommend for Lobster?',\n                brokerPromptPh: 'e.g. Forbidden City theme, Monet style, dungeon theme, Terracotta Warriors theme...',\n                brokerNeedPrompt: 'Please enter broker style prompt first',\n                brokerGenerating: '🏘️ Generating room background from broker plan, please wait (20-90s)...',\n                brokerDone: '✅ Broker plan applied and background replaced, refreshing room...',\n                moveSuccess: '✅ Move successful!',\n                brokerMissingKey: '❌ Generation failed: missing GEMINI API key. Fill it below and retry.',\n                geminiPanelTitle: '🔐 API Settings (collapsible)', geminiHint: 'Optional: set your image API key (base features work without it)', geminiApiDoc: '📘 How to get a Google API Key', geminiInputPh: 'Paste GEMINI_API_KEY (input hidden)', geminiSaveKey: 'Save Key', geminiMaskNoKey: 'Current: no key configured', geminiMaskHasKey: 'Configured key:',\n                speedModeLabel: 'Render Mode', speedFast: '🍌2', speedQuality: '🍌Pro',\n                searchPlaceholder: 'Search assets (desk / sofa / star)', loaded: 'Loaded', allAssets: 'All Assets',\n                chooseImage: 'Upload Replacement Asset', confirmUpload: 'Confirm & Refresh', uploadPending: 'Pending Upload', uploadTarget: 'Target',\n                assetHintNotInScene: 'This object is not detected in current scene; you can still replace file (effective after refresh)',\n                assetHintDefault: 'Generic asset: keep source size, alpha channel, and visual anchor to avoid drift/distortion',\n                showCoords: 'Show Coords', hideCoords: 'Hide Coords', moveView: 'Pan View', lockView: 'Lock View',\n                memoTitle: 'YESTERDAY NOTES', guestTitle: 'VISITOR LIST', officeTitle: 'Haixin Lobster Office',\n                loadingOffice: 'Loading Star’s pixel office...',\n                panelExpand: 'Expand', panelCollapse: 'Collapse',\n                hiddenTag: 'Hidden', assetListLoaded: 'Assets loaded', sceneCaptured: 'Scene captured', assetListLoadFailed: 'Failed to load assets. Click Refresh and retry',\n                authNeedInput: 'Please enter passcode', authPassOk: 'Passcode verified', authPassWrong: 'Wrong passcode',\n                stateDetailIdle: 'Standby', stateDetailWriting: 'Organizing Docs', stateDetailResearching: 'Researching', stateDetailExecuting: 'Executing Tasks', stateDetailSyncing: 'Syncing Backup', stateDetailError: 'Error',\n                stateLabelIdle: 'Standby', stateLabelWriting: 'Organizing Docs', stateLabelResearching: 'Researching', stateLabelExecuting: 'Executing Tasks', stateLabelSyncing: 'Syncing Backup', stateLabelError: 'Error',\n                statusBrokerDecorating: 'Applying broker decoration plan', statusMovingHome: 'Moving to a new home', statusRestoreHome: 'Restoring home background', statusRestoreLastBg: 'Restoring previous generated background', statusApplyFavorite: 'Applying favorite map'\n            },\n            ja: {\n                controlTitle: 'Star ステータス',\n                btnIdle: '待機', btnWork: '作業', btnSync: '同期', btnError: '警報', btnDecor: '部屋を編集',\n                drawerTitle: '部屋編集・アセットサイドバー', drawerClose: '閉じる',\n                authTitle: '編集パスコードを入力', authPlaceholder: 'パスコード入力', authVerify: '認証', authDefaultPassHint: '初期パスコード：1234（いつでも変更を相談可。強固なパス推奨）',\n                drawerVisibilityTip: '表示切替：各行右側の目ボタンで資産を表示/非表示',\n                hideDrawer: '👁 サイドバーを隠す', showDrawer: '👁 サイドバーを表示',\n                assetHide: '非表示', assetShow: '表示',\n                resetToDefault: 'デフォルトへ戻す', restorePrevAsset: '前の版へ戻す',\n                btnMove: '📦 引っ越し', btnHome: '🐚 実家に戻る', btnHomeLast: '↩️ ひとつ前へ', btnHomeFavorite: '⭐ この家を保存', btnBroker: '🤝 仲介', btnDIY: '🪚 自分で装飾', btnBrokerGo: '仲介に任せる',\n                homeFavTitle: '🏠 保存した家', homeFavEmpty: 'まだ保存がありません。先に「⭐ この家を保存」を押してください。', homeFavApply: '現在のマップに適用', homeFavSaved: '✅ 現在のマップを保存しました', homeFavApplied: '✅ 保存した家を適用しました',\n                brokerHint: 'ロブスターにはどんな家をおすすめしますか',\n                brokerPromptPh: '例：故宮テーマ、モネ風、ダンジョン風、兵馬俑テーマ…',\n                brokerNeedPrompt: '先に仲介プランの説明を入力してください',\n                brokerGenerating: '🏘️ 仲介プランで背景を生成中（20〜90秒）...',\n                brokerDone: '✅ 仲介プランを適用して背景を更新しました。部屋を更新中...',\n                moveSuccess: '✅ 引っ越し成功！',\n                brokerMissingKey: '❌ 生成失敗：GEMINI APIキーが未設定です。下で入力して保存してください。',\n                geminiPanelTitle: '🔐 API設定（折りたたみ）', geminiHint: '任意：画像生成APIキーを設定（未設定でも基本機能は利用可）', geminiApiDoc: '📘 Google API Keyの取得方法', geminiInputPh: 'GEMINI_API_KEY を貼り付け（入力は非表示）', geminiSaveKey: 'Keyを保存', geminiMaskNoKey: '現在：キー未設定', geminiMaskHasKey: '設定済みキー：',\n                speedModeLabel: '生成モード', speedFast: '🍌2', speedQuality: '🍌Pro',\n                searchPlaceholder: 'アセット検索（desk / sofa / star）', loaded: '読み込み済み', allAssets: '全アセット',\n                chooseImage: '差し替え素材をアップロード', confirmUpload: '確定して更新', uploadPending: 'アップロード待ち', uploadTarget: '対象',\n                assetHintNotInScene: '現在のシーンでこのオブジェクトは未検出です。ファイル差し替えは可能（更新後に反映）',\n                assetHintDefault: '汎用素材：元サイズ・透過・視覚アンカーを維持し、ズレや崩れを防いでください',\n                showCoords: '座標表示', hideCoords: '座標非表示', moveView: '視点移動', lockView: '視点固定',\n                memoTitle: '昨日のメモ', guestTitle: '訪問者リスト', officeTitle: 'ハイシン・ロブスターのオフィス',\n                loadingOffice: 'Star のピクセルオフィスを読み込み中...',\n                panelExpand: '展開', panelCollapse: '折りたたむ',\n                hiddenTag: '非表示', assetListLoaded: 'アセット読み込み', sceneCaptured: 'シーン取得', assetListLoadFailed: 'アセット読み込み失敗。更新して再試行してください',\n                authNeedInput: 'パスコードを入力してください', authPassOk: '認証に成功しました', authPassWrong: 'パスコードが正しくありません',\n                stateDetailIdle: '待機', stateDetailWriting: '文書整理', stateDetailResearching: '情報検索', stateDetailExecuting: 'タスク実行', stateDetailSyncing: '同期バックアップ', stateDetailError: 'エラー発生',\n                stateLabelIdle: '待機', stateLabelWriting: '文書整理', stateLabelResearching: '情報検索', stateLabelExecuting: 'タスク実行', stateLabelSyncing: '同期バックアップ', stateLabelError: 'エラー発生',\n                statusBrokerDecorating: '仲介の装飾プランを処理中', statusMovingHome: '引っ越し中', statusRestoreHome: 'ホーム背景を復元中', statusRestoreLastBg: '前回背景へ復元中', statusApplyFavorite: '保存したマップを適用中'\n            }\n        };\n\n        function t(key) { return (I18N[uiLang] && I18N[uiLang][key]) || key; }\n        function getOfficePlaqueTitle() {\n            return (window.officeNameFromServer || officePlaqueCustomTitle || t('officeTitle'));\n        }\n        function refreshOfficePlaqueTitle() {\n            const el = document.getElementById('office-plaque-text');\n            if (!el || el.dataset.editing === '1') return;\n            el.textContent = getOfficePlaqueTitle();\n        }\n        function saveOfficePlaqueTitle(raw) {\n            const next = (raw || '').trim();\n            officePlaqueCustomTitle = next;\n            if (next) localStorage.setItem(OFFICE_PLAQUE_STORAGE_KEY, next);\n            else localStorage.removeItem(OFFICE_PLAQUE_STORAGE_KEY);\n        }\n        function initOfficePlaqueEditor() {\n            const plaque = document.getElementById('office-plaque-dom');\n            const textEl = document.getElementById('office-plaque-text');\n            if (!plaque || !textEl) return;\n\n            const beginEdit = () => {\n                if (textEl.dataset.editing === '1') return;\n                textEl.dataset.editing = '1';\n                textEl.contentEditable = 'true';\n                textEl.spellcheck = false;\n                textEl.classList.add('editing');\n                textEl.focus();\n                const sel = window.getSelection();\n                if (sel) {\n                    const range = document.createRange();\n                    range.selectNodeContents(textEl);\n                    range.collapse(false);\n                    sel.removeAllRanges();\n                    sel.addRange(range);\n                }\n            };\n\n            const finishEdit = (shouldSave) => {\n                if (textEl.dataset.editing !== '1') return;\n                textEl.contentEditable = 'false';\n                textEl.dataset.editing = '0';\n                textEl.classList.remove('editing');\n                if (shouldSave) saveOfficePlaqueTitle(textEl.textContent || '');\n                refreshOfficePlaqueTitle();\n            };\n\n            plaque.addEventListener('click', () => beginEdit());\n            textEl.addEventListener('keydown', (e) => {\n                if (e.key === 'Enter') {\n                    e.preventDefault();\n                    finishEdit(true);\n                } else if (e.key === 'Escape') {\n                    e.preventDefault();\n                    textEl.textContent = getOfficePlaqueTitle();\n                    finishEdit(false);\n                }\n            });\n            textEl.addEventListener('blur', () => finishEdit(true));\n            refreshOfficePlaqueTitle();\n        }\n        function renderBootLoadingText(percent) {\n            const loadingEl = document.getElementById('loading-text');\n            if (!loadingEl) return;\n            const base = t('loadingOffice');\n            const p = Number.isFinite(percent) ? ` ${Math.max(0, Math.min(100, Math.round(percent)))}%` : '';\n            loadingEl.textContent = `${base}${p}`;\n        }\n\n        function getStateDetailByState(state) {\n            const keyMap = {\n                idle: 'stateDetailIdle',\n                writing: 'stateDetailWriting',\n                researching: 'stateDetailResearching',\n                executing: 'stateDetailExecuting',\n                syncing: 'stateDetailSyncing',\n                error: 'stateDetailError'\n            };\n            return t(keyMap[state] || 'stateDetailIdle');\n        }\n\n        function getStateLabelByState(state) {\n            if (state === 'syncing') return t('stateLabelSyncing');\n            if (state === 'error') return t('stateLabelError');\n            if (state === 'researching') return t('stateLabelResearching');\n            if (state === 'executing') return t('stateLabelExecuting');\n            if (state === 'writing') return t('stateLabelWriting');\n            return t('stateLabelIdle');\n        }\n\n        async function syncDesktopUiLanguage() {\n            if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.core) return;\n            try {\n                await window.__TAURI__.core.invoke('set_ui_lang', { lang: uiLang });\n            } catch (_) {}\n        }\n\n        function ensureMemoBgVisible() {\n            const panel = document.getElementById('memo-panel');\n            if (!panel) return;\n            panel.style.backgroundImage = \"url('/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}')\";\n            panel.classList.remove('no-bg');\n        }\n\n        function applyLanguage() {\n            const setText = (id, key) => { const el = document.getElementById(id); if (el) el.textContent = t(key); };\n            const setPh = (id, key) => { const el = document.getElementById(id); if (el) el.placeholder = t(key); };\n\n            setText('control-bar-title', 'controlTitle');\n            setText('btn-state-idle', 'btnIdle');\n            setText('btn-state-writing', 'btnWork');\n            setText('btn-state-syncing', 'btnSync');\n            setText('btn-state-error', 'btnError');\n            setText('btn-open-drawer', 'btnDecor');\n            const langButtons = [\n                { id: 'lang-btn-en', lang: 'en' },\n                { id: 'lang-btn-jp', lang: 'ja' },\n                { id: 'lang-btn-cn', lang: 'zh' }\n            ];\n            langButtons.forEach(({ id, lang }) => {\n                const el = document.getElementById(id);\n                if (!el) return;\n                const active = (uiLang === lang);\n                el.classList.toggle('lang-active', active);\n            });\n\n            const drawerTitle = document.querySelector('#asset-drawer-header span');\n            if (drawerTitle) drawerTitle.textContent = t('drawerTitle');\n            const drawerClose = document.getElementById('btn-close-drawer');\n            if (drawerClose) drawerClose.textContent = t('drawerClose');\n\n            const authTitle = document.querySelector('#asset-auth-gate .asset-preview-title');\n            if (authTitle) authTitle.textContent = t('authTitle');\n            setPh('asset-pass-input', 'authPlaceholder');\n            const authVerifyBtn = document.querySelector('#asset-auth-gate .asset-toolbar button');\n            if (authVerifyBtn) authVerifyBtn.textContent = t('authVerify');\n\n            setText('btn-move-house', 'btnMove');\n            setText('btn-back-home', 'btnHome');\n            const brokerBtn = document.querySelector('#asset-broker-row .btn-broker'); if (brokerBtn) brokerBtn.textContent = t('btnBroker');\n            const diyBtn = document.querySelector('#asset-broker-row .btn-diy'); if (diyBtn) diyBtn.textContent = t('btnDIY');\n            const backLastBtn = document.getElementById('btn-back-last-bg'); if (backLastBtn) backLastBtn.textContent = t('btnHomeLast');\n            const favHomeBtn = document.getElementById('btn-favorite-home'); if (favHomeBtn) favHomeBtn.textContent = t('btnHomeFavorite');\n            const favTitle = document.getElementById('asset-home-favorites-title'); if (favTitle) favTitle.textContent = t('homeFavTitle');\n            const brokerHint = document.querySelector('#asset-broker-panel .asset-sub'); if (brokerHint) brokerHint.textContent = t('brokerHint');\n            const brokerPrompt = document.getElementById('asset-broker-prompt'); if (brokerPrompt) brokerPrompt.placeholder = t('brokerPromptPh');\n            const brokerGoBtn = document.querySelector('#asset-broker-actions button'); if (brokerGoBtn) brokerGoBtn.textContent = t('btnBrokerGo');\n            const speedLbl = document.getElementById('speed-mode-label'); if (speedLbl) speedLbl.textContent = t('speedModeLabel');\n            const speedFastBtn = document.getElementById('speed-fast-btn'); if (speedFastBtn) speedFastBtn.textContent = t('speedFast');\n            const speedQualityBtn = document.getElementById('speed-quality-btn'); if (speedQualityBtn) speedQualityBtn.textContent = t('speedQuality');\n            const geminiPanelSummary = document.getElementById('gemini-panel-summary'); if (geminiPanelSummary) geminiPanelSummary.textContent = t('geminiPanelTitle');\n            const geminiHint = document.getElementById('gemini-config-hint'); if (geminiHint) geminiHint.textContent = t('geminiHint');\n            const geminiDocLink = document.getElementById('gemini-api-doc-link'); if (geminiDocLink) geminiDocLink.textContent = t('geminiApiDoc');\n            const geminiInput = document.getElementById('gemini-api-key-input'); if (geminiInput) geminiInput.placeholder = t('geminiInputPh');\n            const geminiSaveBtn = document.getElementById('btn-save-gemini-key'); if (geminiSaveBtn) geminiSaveBtn.textContent = t('geminiSaveKey');\n\n            setPh('asset-search', 'searchPlaceholder');\n\n            setText('asset-choose-btn', 'chooseImage');\n            setText('asset-commit-refresh-btn', 'confirmUpload');\n\n            const memoTitle = document.getElementById('memo-title');\n            if (memoTitle) {\n                memoTitle.textContent = t('memoTitle');\n                memoTitle.dataset.baseTitle = t('memoTitle');\n            }\n            const controlTitle = document.getElementById('control-bar-title');\n            if (controlTitle) controlTitle.dataset.baseTitle = t('controlTitle');\n            const guestTitle = document.getElementById('guest-agent-panel-title');\n            if (guestTitle) {\n                guestTitle.textContent = t('guestTitle');\n                guestTitle.dataset.baseTitle = t('guestTitle');\n            }\n            refreshOfficePlaqueTitle();\n\n            refreshCollapsiblePanelTitles();\n\n            const coordsBtn = document.getElementById('coords-toggle');\n            if (coordsBtn) coordsBtn.textContent = showCoords ? t('hideCoords') : t('showCoords');\n            const panBtn = document.getElementById('pan-toggle');\n            if (panBtn) {\n                const on = panBtn.dataset.on === '1';\n                panBtn.textContent = on ? t('lockView') : t('moveView');\n            }\n            ensureMemoBgVisible();\n            renderBootLoadingText(Number(loadingProgressBar?.style?.width?.replace('%','') || 0));\n            syncDesktopUiLanguage();\n        }\n\n        function setUILanguage(lang) {\n            if (!['zh', 'en', 'ja'].includes(lang)) return;\n            uiLang = lang;\n            localStorage.setItem('uiLang', uiLang);\n            applyLanguage();\n            updateSpeedModeUI();\n\n            // 语言切换后立即重绘资产侧栏，确保易懂名同步更新\n            renderAssetDrawerList();\n\n            // 语言切换后同步刷新已选资产的指导文案（上传区小字三语联动）\n            if (selectedAssetInfo && selectedAssetInfo.path) {\n                const inScene = !!mapAssetPathToSprite(selectedAssetInfo.path);\n                renderSelectedAssetGuidance(selectedAssetInfo.path, inScene);\n            }\n\n            // 语言切换时，当前正在显示的 loading 文案也实时切换\n            const overlay = document.getElementById('room-loading-overlay');\n            if (overlay && overlay.style.display === 'flex') {\n                showRoomLoadingOverlay();\n            }\n        }\n\n        // 检测浏览器是否支持 WebP\n        let supportsWebP = false;\n        \n        // 方法 1: 使用 canvas 检测\n        function checkWebPSupport() {\n            return new Promise((resolve) => {\n                const canvas = document.createElement('canvas');\n                if (canvas.getContext && canvas.getContext('2d')) {\n                    resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0);\n                } else {\n                    resolve(false);\n                }\n            });\n        }\n        \n        // 方法 2: 使用 image 检测（备用）\n        function checkWebPSupportFallback() {\n            return new Promise((resolve) => {\n                const img = new Image();\n                img.onload = () => resolve(true);\n                img.onerror = () => resolve(false);\n                img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA==';\n            });\n        }\n\n        const PAGE_PARAMS = new URLSearchParams(window.location.search);\n        const ELECTRON_MODE = !!window.__ELECTRON__;\n        const DESKTOP_MODE = !!window.__TAURI__ || PAGE_PARAMS.get('desktop') === '1';\n        const ASSET_WINDOW_MODE = PAGE_PARAMS.get('assetWindow') === '1';\n        if (DESKTOP_MODE) {\n            document.body.classList.add('desktop-shell');\n        }\n        if (ASSET_WINDOW_MODE) {\n            document.body.classList.add('asset-window-mode');\n            const drawer = document.getElementById('asset-drawer');\n            const drawerHeader = document.getElementById('asset-drawer-header');\n            if (drawer) drawer.removeAttribute('data-no-window-drag');\n            if (drawerHeader) drawerHeader.removeAttribute('data-no-window-drag');\n        }\n        if (ELECTRON_MODE) {\n            document.body.classList.add('electron-shell');\n            if (!ASSET_WINDOW_MODE) {\n                const drawer = document.getElementById('asset-drawer');\n                const backdrop = document.getElementById('asset-drawer-backdrop');\n                if (drawer) drawer.style.display = 'none';\n                if (backdrop) backdrop.style.display = 'none';\n            }\n        }\n\n        function initDesktopWindowDrag() {\n            if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.window) return;\n            const appWindow = window.__TAURI__.window.getCurrentWindow();\n            let dragStart = null;\n            let dragTriggered = false;\n            const DRAG_THRESHOLD = 8;\n\n            const shouldIgnoreTarget = (target) => {\n                if (!target || !(target instanceof Element)) return false;\n                return !!target.closest('button, a, input, textarea, select, [contenteditable], [data-no-window-drag]');\n            };\n\n            document.addEventListener('pointerdown', (e) => {\n                if (e.button !== 0) return;\n                if (shouldIgnoreTarget(e.target)) return;\n                dragStart = { x: e.clientX, y: e.clientY };\n                dragTriggered = false;\n            });\n\n            document.addEventListener('pointermove', async (e) => {\n                if (!dragStart || dragTriggered) return;\n                const dx = e.clientX - dragStart.x;\n                const dy = e.clientY - dragStart.y;\n                const moved = Math.hypot(dx, dy);\n                if (moved < DRAG_THRESHOLD) return;\n                dragTriggered = true;\n                try {\n                    await appWindow.startDragging();\n                } catch (_) {\n                    // ignore drag API errors\n                } finally {\n                    dragStart = null;\n                }\n            });\n\n            const clearDrag = () => {\n                dragStart = null;\n                dragTriggered = false;\n            };\n            document.addEventListener('pointerup', clearDrag);\n            document.addEventListener('pointercancel', clearDrag);\n        }\n        initDesktopWindowDrag();\n\n        function initWindowControls() {\n            if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.core) return;\n            const core = window.__TAURI__.core;\n            const closeBtn = document.getElementById('btn-close');\n            const miniBtn = document.getElementById('btn-minimize-mode');\n            const maxBtn = document.getElementById('btn-open-frontend');\n            if (closeBtn) {\n                closeBtn.addEventListener('click', async (e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    try { await core.invoke('close_app'); } catch (_) {}\n                });\n            }\n            if (miniBtn) {\n                miniBtn.addEventListener('click', async (e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    try { await core.invoke('enter_minimize_mode'); } catch (_) {}\n                });\n            }\n            if (maxBtn) {\n                maxBtn.addEventListener('click', async (e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    try {\n                        const url = new URL('/', window.location.origin).toString();\n                        if (core && core.invoke) {\n                            await core.invoke('open_external_url', { url });\n                        } else {\n                            window.open(url.toString(), '_blank', 'noopener,noreferrer');\n                        }\n                    } catch (_) {\n                        window.open('http://127.0.0.1:19000/', '_blank', 'noopener,noreferrer');\n                    }\n                });\n            }\n        }\n        initWindowControls();\n\n        function initMainWindowAssetRefreshSync() {\n            if (ASSET_WINDOW_MODE) return;\n            if (!ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.event) return;\n            window.__TAURI__.event.listen('main-window-asset-refresh', async (evt) => {\n                const payload = (evt && evt.payload) ? evt.payload : {};\n                const kind = String(payload.kind || 'asset');\n                const path = String(payload.path || '');\n                try {\n                    if (kind === 'asset_action') {\n                        const action = String(payload.action || '');\n                        if (action === 'preview_asset' && path) {\n                            applyScenePreview(path);\n                        } else if (action === 'clear_preview') {\n                            clearAssetSelectionUI();\n                        } else if (action === 'set_visibility' && path) {\n                            const visible = !!payload.visible;\n                            setAssetVisible(path, visible);\n                            if (selectedAssetInfo && selectedAssetInfo.path === path) {\n                                if (visible) applyScenePreview(path);\n                                else clearAssetSelectionUI();\n                            }\n                        }\n                    } else if (kind === 'office_bg') {\n                        await refreshOfficeBackgroundOnly();\n                    } else if (path) {\n                        await refreshSceneObjectByAssetPath(path);\n                    } else {\n                        await refreshOfficeBackgroundOnly();\n                    }\n                    if (assetDrawerOpen && assetDrawerAuthed) {\n                        await refreshAssetDrawerList();\n                        await renderHomeFavorites(false);\n                    }\n                } catch (_) {}\n            });\n        }\n        initMainWindowAssetRefreshSync();\n\n        function refreshCollapsiblePanelTitles() {\n            const defs = [\n                { panelId: 'memo-panel', titleId: 'memo-title' },\n                { panelId: 'control-bar', titleId: 'control-bar-title' },\n                { panelId: 'guest-agent-panel', titleId: 'guest-agent-panel-title' }\n            ];\n            defs.forEach(({ panelId, titleId }) => {\n                const panel = document.getElementById(panelId);\n                const title = document.getElementById(titleId);\n                if (!panel || !title) return;\n                const base = (title.textContent || '').replace(/\\s*\\[[^\\]]+\\]\\s*$/, '').trim();\n                title.dataset.baseTitle = base;\n                const collapsed = panel.classList.contains('collapsed');\n                title.textContent = `${base} [${collapsed ? t('panelExpand') : t('panelCollapse')}]`;\n            });\n        }\n\n        function initCollapsiblePanels() {\n            const defs = [\n                { panelId: 'memo-panel', titleId: 'memo-title' },\n                { panelId: 'control-bar', titleId: 'control-bar-title' },\n                { panelId: 'guest-agent-panel', titleId: 'guest-agent-panel-title' }\n            ];\n\n            defs.forEach(({ panelId, titleId }) => {\n                const panel = document.getElementById(panelId);\n                const title = document.getElementById(titleId);\n                if (!panel || !title) return;\n\n                const baseTitle = (title.textContent || '').replace(/\\s*\\[[^\\]]+\\]\\s*$/, '').trim();\n                title.dataset.baseTitle = baseTitle;\n                title.classList.add('panel-toggle-title');\n                panel.classList.add('panel-collapsible');\n\n                const updateTitle = () => {\n                    const collapsed = panel.classList.contains('collapsed');\n                    title.textContent = `${title.dataset.baseTitle} [${collapsed ? t('panelExpand') : t('panelCollapse')}]`;\n                };\n\n                const syncElectronWindowMode = async () => {\n                    // asset window must NEVER resize main window.\n                    if (ASSET_WINDOW_MODE) return;\n                    if (!ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return;\n                    const expanded = !!document.querySelector('.panel-collapsible:not(.collapsed)');\n                    try {\n                        await window.__TAURI__.core.invoke('set_main_window_mode', { expanded });\n                    } catch (_) {}\n                };\n\n                title.addEventListener('click', () => {\n                    panel.classList.toggle('collapsed');\n                    updateTitle();\n                    queueDesktopResize();\n                    syncElectronWindowMode();\n                    setTimeout(queueDesktopResize, 260);\n                    setTimeout(syncElectronWindowMode, 260);\n                });\n\n                panel.classList.add('collapsed');\n                updateTitle();\n                syncElectronWindowMode();\n            });\n        }\n\n        let resizeTimer = null;\n        async function syncDesktopWindowSize() {\n            if (!DESKTOP_MODE || !window.__TAURI__ || !window.__TAURI__.core) return;\n            const expanded = !!document.querySelector('.panel-collapsible:not(.collapsed)');\n            try {\n                await window.__TAURI__.core.invoke('set_main_window_mode', { expanded });\n            } catch (_) {}\n        }\n        function queueDesktopResize() {\n            if (resizeTimer) clearTimeout(resizeTimer);\n            resizeTimer = setTimeout(() => { syncDesktopWindowSize(); }, 40);\n        }\n\n        initCollapsiblePanels();\n        queueDesktopResize();\n        window.addEventListener('resize', () => { if (DESKTOP_MODE) queueDesktopResize(); });\n        window.addEventListener('load', () => { if (DESKTOP_MODE) queueDesktopResize(); });\n        if (DESKTOP_MODE && document.fonts && document.fonts.ready) {\n            document.fonts.ready.then(() => {\n                queueDesktopResize();\n                setTimeout(queueDesktopResize, 120);\n            }).catch(() => {});\n        }\n\n        const IS_TOUCH_DEVICE = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.matchMedia('(pointer: coarse)').matches;\n        // Desktop-only fill zoom to crop decorative side bands and make main room occupy more width.\n        const DESKTOP_FILL_ZOOM = 1.14;\n\n        const config = {\n            type: Phaser.AUTO,\n            width: 1280,\n            height: 720,\n            parent: 'game-container',\n            pixelArt: true,\n            // 桌面端保持 FIT；手机端用 RESIZE，并在相机里按高度做 fit（可横向 pan）\n            scale: {\n                mode: IS_TOUCH_DEVICE ? Phaser.Scale.RESIZE : Phaser.Scale.FIT,\n                autoCenter: Phaser.Scale.CENTER_BOTH,\n                width: 1280,\n                height: 720\n            },\n            physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },\n            scene: { preload: preload, create: create, update: update }\n        };\n\n        let totalAssets = 0;\n        let loadedAssets = 0;\n        let loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText;\n\n        // Memo 相关函数\n        async function loadMemo() {\n            const memoDate = document.getElementById('memo-date');\n            const memoContent = document.getElementById('memo-content');\n            \n            try {\n                const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' });\n                const data = await response.json();\n                \n                if (data.success && data.memo) {\n                    memoDate.textContent = data.date || '';\n                    memoContent.innerHTML = data.memo.replace(/\\n/g, '<br>');\n                } else {\n                    memoContent.innerHTML = '<div id=\"memo-placeholder\">暂无昨日日记</div>';\n                }\n            } catch (e) {\n                console.error('加载 memo 失败:', e);\n                memoContent.innerHTML = '<div id=\"memo-placeholder\">加载失败</div>';\n            }\n        }\n\n        // 更新加载进度\n        function updateLoadingProgress() {\n            loadedAssets++;\n            const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100));\n            if (loadingProgressBar) {\n                loadingProgressBar.style.width = percent + '%';\n            }\n            if (loadingText) {\n                renderBootLoadingText(percent);\n            }\n        }\n\n        // 隐藏加载界面\n        function hideLoadingOverlay() {\n            setTimeout(() => {\n                if (loadingOverlay) {\n                    loadingOverlay.style.transition = 'opacity 0.5s ease';\n                    loadingOverlay.style.opacity = '0';\n                    setTimeout(() => {\n                        loadingOverlay.style.display = 'none';\n                    }, 500);\n                }\n            }, 300);\n        }\n\n        // 兜底：某些移动网络/CDN 抖动时，避免一直卡在“加载中”遮罩\n        setTimeout(() => {\n            if (loadingOverlay && loadingOverlay.style.display !== 'none') {\n                hideLoadingOverlay();\n            }\n        }, 8000);\n        \n        // 懒加载逻辑已取消（体验优先：装饰首屏直接出现）\n\n        const STATES = {\n            idle: { name: '待命', area: 'breakroom' },\n            writing: { name: '整理文档', area: 'writing' },\n            researching: { name: '搜索信息', area: 'researching' },\n            executing: { name: '执行任务', area: 'writing' },\n            syncing: { name: '同步备份', area: 'writing' },\n            error: { name: '出错了', area: 'error' }\n        };\n\n        const BUBBLE_TEXTS = {\n            zh: {\n                idle: ['待命中：耳朵竖起来了','我在这儿，随时可以开工','先把桌面收拾干净再说','呼——给大脑放个风','今天也要优雅地高效','等待，是为了更准确的一击','咖啡还热，灵感也还在','我在后台给你加 Buff','状态：静心 / 充电','小猫说：慢一点也没关系'],\n                writing: ['进入专注模式：勿扰','先把关键路径跑通','我来把复杂变简单','把 bug 关进笼子里','写到一半，先保存','把每一步都做成可回滚','今天的进度，明天的底气','先收敛，再发散','让系统变得更可解释','稳住，我们能赢'],\n                researching: ['我在挖证据链','让我把信息熬成结论','找到了：关键在这里','先把变量控制住','我在查：它为什么会这样','把直觉写成验证','先定位，再优化','别急，先画因果图'],\n                executing: ['执行中：不要眨眼','把任务切成小块逐个击破','开始跑 pipeline','一键推进：走你','让结果自己说话','先做最小可行，再做最美版本'],\n                syncing: ['同步中：把今天锁进云里','备份不是仪式，是安全感','写入中…别断电','把变更交给时间戳','云端对齐：咔哒','同步完成前先别乱动','把未来的自己从灾难里救出来','多一份备份，少一份后悔'],\n                error: ['警报响了：先别慌','我闻到 bug 的味道了','先复现，再谈修复','把日志给我，我会说人话','错误不是敌人，是线索','把影响面圈起来','先止血，再手术','我在：马上定位根因','别怕，这种我见多了','报警中：让问题自己现形'],\n                cat: ['喵~','咕噜咕噜…','尾巴摇一摇','晒太阳最开心','有人来看我啦','我是这个办公室的吉祥物','伸个懒腰','今天的罐罐准备好了吗','呼噜呼噜','这个位置视野最好']\n            },\n            en: {\n                idle: ['On standby: ears up.','I’m here, ready to roll.','Let’s tidy the desk first.','Taking a quick brain breeze.','Efficient and elegant, as always.','Waiting for a more precise strike.','Coffee is warm, ideas too.','Giving you a quiet backstage buff.','Status: calm / charging.','Cat says: no rush, we’re good.'],\n                writing: ['Focus mode on: do not disturb.','Let’s clear the critical path first.','I’ll make the complex simple.','Putting bugs in a cage.','Save first, then continue.','Every step should be rollback-safe.','Today’s progress is tomorrow’s confidence.','Converge first, then diverge.','Making the system more explainable.','Steady—this is winnable.'],\n                researching: ['Digging the evidence chain.','Let me boil info into conclusions.','Found it: key clue here.','Control variables first.','Checking why this happens.','Turn intuition into verification.','Locate first, optimize next.','No rush—draw the causality map first.'],\n                executing: ['Executing—don’t blink.','Split tasks, conquer one by one.','Pipeline is running.','One-click push: go go.','Let the results speak.','Build MVP first, then craft beauty.'],\n                syncing: ['Syncing: lock today into the cloud.','Backup is safety, not ceremony.','Writing… don’t cut power.','Handing changes to timestamps.','Cloud alignment: click.','Don’t shake it before sync finishes.','Saving future-us from disasters.','One more backup, one less regret.'],\n                error: ['Alarm on—stay calm.','I can smell a bug.','Reproduce first, then fix.','Give me logs; I’ll translate.','Errors are clues, not enemies.','Circle the impact area first.','Stop the bleeding, then surgery.','On it: tracing root cause now.','Don’t worry, seen this many times.','Alert mode: make the issue reveal itself.'],\n                cat: ['Meow~','Purr purr…','Tail wiggle activated.','Sunbathing is the best.','Someone came to see me!','I’m the office mascot.','Big stretch~','Is today’s snack ready yet?','Rrrrr purr…','Best view spot secured.']\n            },\n            ja: {\n                idle: ['待機中：耳はピン。','ここにいるよ、いつでも開始OK。','まず机を整えよう。','ふー、頭に風を通す。','今日も上品に高効率で。','待つのは、より正確な一撃のため。','コーヒーも発想もまだ温かい。','裏側でそっとバフ中。','状態：静心 / 充電。','猫より：ゆっくりでも大丈夫。'],\n                writing: ['集中モード：お静かに。','まずはクリティカルパスを通す。','複雑をシンプルにする。','バグはケージへ。','途中でもまず保存。','すべてをロールバック可能に。','今日の進捗は明日の自信。','まず収束、次に発散。','システムをより説明可能に。','落ち着いて、勝てる。'],\n                researching: ['証拠チェーンを掘っています。','情報を結論まで煮詰めます。','見つけた：鍵はここ。','まず変数を制御。','なぜこうなるか調査中。','直感を検証へ。','先に特定、次に最適化。','急がず因果マップから。'],\n                executing: ['実行中：まばたき厳禁。','タスクを分割して各個撃破。','パイプライン起動。','ワンクリック前進：いくぞ。','結果に語らせる。','まず最小実用、次に美しさ。'],\n                syncing: ['同期中：今日をクラウドに封印。','バックアップは儀式じゃなく安心。','書き込み中…電源オフ厳禁。','変更はタイムスタンプへ。','クラウド整列：カチッ。','同期完了まで触らないで。','未来の自分を災害から救う。','バックアップ一つ、後悔一つ減る。'],\n                error: ['警報：まず落ち着いて。','バグの気配を感じる。','再現してから修正へ。','ログをください、人語にします。','エラーは敵ではなく手がかり。','まず影響範囲を囲う。','止血してから手術。','今すぐ根因を追跡中。','大丈夫、よくある案件。','警戒モード：問題を可視化する。'],\n                cat: ['ニャー','ゴロゴロ…','しっぽフリフリ。','ひなたぼっこ最高。','見に来てくれた！','このオフィスのマスコットです。','ぐーっと伸び。','今日のおやつ、準備できた？','ゴロゴロ。','ここ、いちばん見晴らしがいい。']\n            }\n        };\n\n        let game, star, sofa, serverroom, officeBgSprite, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, syncAnimPlayable = false, catBubble = null, selectionBoxGraphics = null;\n        const IDLE_SOFA_ANCHOR = { x: 798, y: 272 }; // 统一中心锚点（原 sofa 左上 670,144 的中心）\n        const IDLE_STAR_SCALE = 1.0; // star idle 改为256帧原生显示，不再放大\n        // flowers 精灵表规格：固定单帧 128x128，4x4\n        let FLOWERS_FRAME_W = 65;\n        let FLOWERS_FRAME_H = 65;\n        let FLOWERS_FRAME_COLS = 4;\n        let FLOWERS_FRAME_ROWS = 4;\n        let currentOfficeBgTextureKey = 'office_bg';\n        let assetDrawerOpen = false;\n        let assetDrawerAuthed = false;\n        let assetManualPanelOpen = false;\n        let assetFilterMode = 'all';\n        let assetListData = [];\n        let sceneAssetItems = [];\n        let selectedAssetInfo = null;\n        let hiddenAssetPaths = new Set();\n        let assetThumbTimers = [];\n        let homeFavoritesCache = [];\n        let homeFavoritesLoadedAt = 0;\n\n        // 坐标以服务端为准；清理历史本地缓存，避免把素材挪飞\n        let assetPositionOverrides = {};\n        let roomLoadingTimer = null;\n        let roomLoadingIndex = 0;\n        let roomLoadingEmojiIndex = 0;\n\n        // 默认走更稳的模型档（quality），避免部分通道不支持 fast 模型时报错\n        let speedMode = localStorage.getItem('speedMode') || 'quality';\n        function setSpeedMode(mode) {\n            speedMode = (mode === 'quality') ? 'quality' : 'fast';\n            try { localStorage.setItem('speedMode', speedMode); } catch(e) {}\n            updateSpeedModeUI();\n        }\n        function updateSpeedModeUI() {\n            const fastBtn = document.getElementById('speed-fast-btn');\n            const qBtn = document.getElementById('speed-quality-btn');\n            if (!fastBtn || !qBtn) return;\n            const fastOn = speedMode === 'fast';\n            fastBtn.style.background = fastOn ? '#22c55e' : '#334155';\n            fastBtn.style.color = fastOn ? '#052e16' : '#e5e7eb';\n            fastBtn.style.borderColor = fastOn ? '#16a34a' : '#475569';\n            qBtn.style.background = fastOn ? '#334155' : '#22c55e';\n            qBtn.style.color = fastOn ? '#e5e7eb' : '#052e16';\n            qBtn.style.borderColor = fastOn ? '#475569' : '#16a34a';\n        }\n        try { localStorage.removeItem('assetPositionOverrides'); } catch (e) {}\n        let isMoving = false;\n        let waypoints = []; // list of (x,y) to walk through in order\n        let lastWanderAt = 0;\n\n        let coordsOverlay, coordsDisplay, coordsToggle;\n        let showCoords = false;\n        let guestAgents = [];\n        let guestSprites = {}; // agentId -> {sprite, nameText}\n        let guestBubbles = {}; // agentId -> bubble container\n        const GUEST_AVATARS = ['guest_role_1','guest_role_2','guest_role_3','guest_role_4','guest_role_5','guest_role_6'];\n        let guestTweens = {};  // agentId -> {move, name}\n        let hiddenDemoNames = new Set();\n        const DEMO_MODE = new URLSearchParams(window.location.search).get('demo') === '1';\n        const FETCH_INTERVAL = 2000;\n        const GUEST_AGENTS_FETCH_INTERVAL = 3500;\n        const BLINK_INTERVAL = 2500;\n        const BUBBLE_INTERVAL = 8000;\n        const CAT_BUBBLE_INTERVAL = 18000; // cat bubble much less frequent\n        let lastCatBubble = 0;\n        let lastGuestAgentsFetch = 0;\n        let lastGuestBubbleAt = 0;\n        const TYPEWRITER_DELAY = 50;\n        let lastSeenGuestIds = new Set(); // 用于检测新加入的访客，触发欢迎气泡\n        let guestWelcomeInitialized = false;\n\n        // 状态控制栏函数（用于测试）\n        function setState(state, detail) {\n            fetch('/set_state', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ state, detail })\n            })\n            .then((res) => {\n                if (!res.ok) throw new Error(`set_state failed: ${res.status}`);\n                return fetchStatus();\n            })\n            .catch((e) => {\n                console.error('setState failed', e);\n            });\n        }\n\n        function updateAssetAuthUI() {\n            const gate = document.getElementById('asset-auth-gate');\n            const main = document.getElementById('asset-main-content');\n            if (!gate || !main) return;\n            gate.style.display = assetDrawerAuthed ? 'none' : 'block';\n            main.style.display = assetDrawerAuthed ? 'block' : 'none';\n            updateManualPanelUI();\n        }\n\n        function updateManualPanelUI() {\n            const panel = document.getElementById('asset-manual-panel');\n            if (!panel) return;\n            panel.classList.toggle('open', !!assetManualPanelOpen && !!assetDrawerAuthed);\n        }\n\n        async function unlockAssetDrawer() {\n            const input = document.getElementById('asset-pass-input');\n            const msg = document.getElementById('asset-auth-msg');\n            const val = (input?.value || '').trim();\n            if (!val) {\n                if (msg) msg.textContent = `❌ ${t('authNeedInput')}`;\n                return;\n            }\n            try {\n                const res = await fetch('/assets/auth', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ password: val })\n                });\n                const data = await res.json();\n                if (data && data.ok) {\n                    assetDrawerAuthed = true;\n                    if (msg) msg.textContent = `✅ ${t('authPassOk')}`;\n                    updateAssetAuthUI();\n                    await refreshAssetDrawerList();\n                    await renderHomeFavorites(false);\n                    bindDrawerFileMeta();\n                } else {\n                    assetDrawerAuthed = false;\n                    if (msg) msg.textContent = `❌ ${t('authPassWrong')}`;\n                }\n            } catch (e) {\n                assetDrawerAuthed = false;\n                if (msg) msg.textContent = `❌ 验证失败：${e}`;\n            }\n        }\n\n        function formatSizeHuman(n) {\n            if (!n) return '0 KB';\n            if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB';\n            return (n / 1024).toFixed(1) + ' KB';\n        }\n        function toAssetStem(v) {\n            const s = (v || '').toLowerCase();\n            const file = s.split('/').pop() || s;\n            return file.replace(/\\.[^.]+$/, '');\n        }\n\n        function getAssetDisplayName(path) {\n            const stem = toAssetStem(path);\n            const lang = (uiLang || 'zh');\n            const nameMap = {\n                zh: {\n                    'star-idle-v5': '主角·待命状态',\n                    'star-working-spritesheet-grid': '主角·工作状态',\n                    'sync-animation': '主角·同步状态',\n                    'sync-animation-v3-grid': '主角·同步状态',\n                    'error-bug-spritesheet-grid': '主角·报错状态',\n                    'cats-spritesheet': '随机猫猫',\n                    'coffee-machine-v3-grid': '咖啡机',\n                    'coffee-machine-shadow-v1': '咖啡机阴影',\n                    'posters-spritesheet': '随机海报',\n                    'serverroom-spritesheet': '服务器房动画',\n                    'plants-spritesheet': '随机绿植',\n                    'flowers-bloom-v2': '随机花朵',\n                    'office_bg_small': '办公室背景',\n                    'memo-bg': '昨日小记底图',\n                    'desk-v3': '办公桌',\n                    'desk': '办公桌（旧）',\n                    'guest_anim_1': '访客动画 1',\n                    'guest_anim_2': '访客动画 2',\n                    'guest_anim_3': '访客动画 3',\n                    'guest_anim_4': '访客动画 4',\n                    'guest_anim_5': '访客动画 5',\n                    'guest_anim_6': '访客动画 6'\n                },\n                en: {\n                    'star-idle-v5': 'Main · Idle',\n                    'star-working-spritesheet-grid': 'Main · Working',\n                    'sync-animation': 'Main · Syncing',\n                    'sync-animation-v3-grid': 'Main · Syncing',\n                    'error-bug-spritesheet-grid': 'Main · Error',\n                    'cats-spritesheet': 'Random Cats',\n                    'coffee-machine-v3-grid': 'Coffee Machine',\n                    'coffee-machine-shadow-v1': 'Coffee Machine Shadow',\n                    'posters-spritesheet': 'Random Posters',\n                    'serverroom-spritesheet': 'Server Room',\n                    'plants-spritesheet': 'Random Plants',\n                    'flowers-bloom-v2': 'Random Flowers',\n                    'office_bg_small': 'Office Background',\n                    'memo-bg': 'Memo Background',\n                    'desk-v3': 'Desk',\n                    'desk': 'Desk (Old)',\n                    'guest_anim_1': 'Guest Animation 1',\n                    'guest_anim_2': 'Guest Animation 2',\n                    'guest_anim_3': 'Guest Animation 3',\n                    'guest_anim_4': 'Guest Animation 4',\n                    'guest_anim_5': 'Guest Animation 5',\n                    'guest_anim_6': 'Guest Animation 6'\n                },\n                ja: {\n                    'star-idle-v5': 'メイン・待機状態',\n                    'star-working-spritesheet-grid': 'メイン・作業状態',\n                    'sync-animation': 'メイン・同期状態',\n                    'sync-animation-v3-grid': 'メイン・同期状態',\n                    'error-bug-spritesheet-grid': 'メイン・エラー状態',\n                    'cats-spritesheet': 'ランダム猫',\n                    'coffee-machine-v3-grid': 'コーヒーマシン',\n                    'coffee-machine-shadow-v1': 'コーヒーマシン影',\n                    'posters-spritesheet': 'ランダムポスター',\n                    'serverroom-spritesheet': 'サーバールーム',\n                    'plants-spritesheet': 'ランダム植物',\n                    'flowers-bloom-v2': 'ランダム花',\n                    'office_bg_small': 'オフィス背景',\n                    'memo-bg': 'メモ背景',\n                    'desk-v3': 'デスク',\n                    'desk': 'デスク（旧）',\n                    'guest_anim_1': '訪客アニメ 1',\n                    'guest_anim_2': '訪客アニメ 2',\n                    'guest_anim_3': '訪客アニメ 3',\n                    'guest_anim_4': '訪客アニメ 4',\n                    'guest_anim_5': '訪客アニメ 5',\n                    'guest_anim_6': '訪客アニメ 6'\n                }\n            };\n            const langMap = nameMap[lang] || nameMap.zh;\n            return langMap[stem] || stem;\n        }\n\n        const ASSET_HELP_TEXT_MAP = {\n            zh: {\n                'office_bg_small': '主场景底图（当前生效）。建议 1280×720（16:9），保留房间结构与视角，避免角色站位错位。',\n                'office_bg': '历史背景备份。通常不直接生效，建议与 office_bg_small 保持同构图用于回退。',\n                'star-idle-v5': '主角待机动画表。请保持 256×256 分帧与网格布局一致，否则待机动作会错帧。',\n                'star-working-spritesheet-grid': '主角工作动画表（工位状态）。请保持 300×300 分帧，建议人物重心与原图一致。',\n                'sync-animation': '同步状态素材（当前引用）。建议按 256×256 帧规范制作，避免同步状态显示静止或抖动。',\n                'sync-animation-v3-grid': '同步动画表（兼容资源）。保持 256×256 网格可用于替换同步动作细节。',\n                'error-bug-spritesheet-grid': '报错状态动画表。请保持 220×220 分帧，建议高对比度以增强异常提示感。',\n                'desk-v3': '办公桌前景层。影响主角前后遮挡关系，建议保持当前比例与锚点视觉重心。',\n                'desk': '旧版办公桌素材（兼容用）。建议与 desk-v3 保持相近体积与锚点，避免遮挡异常。',\n                'sofa-idle-v3': '沙发静态素材。建议保持 256×256 与透明背景，避免替换后位置漂移。',\n                'sofa-shadow-v1': '沙发阴影层。建议与沙发主体同坐标叠放，增强贴地感。',\n                'memo-bg': '小记面板底图。建议留出文字阅读区域，降低高频纹理，避免信息难读。',\n                'plants-spritesheet': '绿植随机素材。保持 160×160 分帧，可一次替换多个绿植位的观感。',\n                'posters-spritesheet': '海报随机素材。保持 160×160 分帧，建议统一风格避免墙面杂乱。',\n                'cats-spritesheet': '猫咪随机素材。保持 160×160 分帧，建议轮廓清晰、识别度高。',\n                'coffee-machine-v3-grid': '咖啡机静态素材。建议保持 230×230 与当前锚点，避免位置偏移。',\n                'coffee-machine-shadow-v1': '咖啡机阴影层。建议与咖啡机本体同宽对齐，增强贴地感。',\n                'serverroom-spritesheet': '服务器房动画表。保持 180×251 分帧，灯效变化建议节奏均匀不过闪。',\n                'flowers-bloom-v2': '花朵随机素材。保持 128×128 分帧，建议色彩与整体办公室主色协调。',\n                'guest_anim_1': '访客动画序列 1（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_2': '访客动画序列 2（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_3': '访客动画序列 3（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_4': '访客动画序列 4（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_5': '访客动画序列 5（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_6': '访客动画序列 6（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_role_1': '访客静态形象备用图 1。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_2': '访客静态形象备用图 2。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_3': '访客静态形象备用图 3。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_4': '访客静态形象备用图 4。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_5': '访客静态形象备用图 5。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_6': '访客静态形象备用图 6。建议与对应 guest_anim 角色设定一致，避免切换割裂。'\n            },\n            en: {\n                'office_bg_small': 'Primary room background (active). Use 1280×720 (16:9), keep room structure/perspective to avoid character misalignment.',\n                'office_bg': 'Legacy backup background. Usually not directly active; keep composition aligned with office_bg_small for rollback.',\n                'star-idle-v5': 'Main idle spritesheet. Keep 256×256 frame size and grid layout, or idle animation will break.',\n                'star-working-spritesheet-grid': 'Main working spritesheet (desk state). Keep 300×300 frames; preserve visual center/anchor.',\n                'sync-animation': 'Sync-state asset (currently referenced). Follow 256×256 frame spec to avoid static/jitter sync visuals.',\n                'sync-animation-v3-grid': 'Sync spritesheet (compat resource). Keep 256×256 grid for sync animation replacement.',\n                'error-bug-spritesheet-grid': 'Error-state spritesheet. Keep 220×220 frames; high contrast helps warning readability.',\n                'desk-v3': 'Desk foreground layer. Controls overlap with character; keep ratio and visual anchor stable.',\n                'desk': 'Legacy desk asset (compatibility). Keep size/anchor close to desk-v3 to avoid overlap issues.',\n                'sofa-idle-v3': 'Static sofa asset. Keep 256×256 and transparent background to prevent position drift.',\n                'sofa-shadow-v1': 'Sofa shadow layer. Keep the exact same coordinates as sofa body for grounded feel.',\n                'memo-bg': 'Memo panel background. Reserve readable text area; avoid dense textures behind text.',\n                'plants-spritesheet': 'Random plant sprites. Keep 160×160 frames; updates several plant spots at once.',\n                'posters-spritesheet': 'Random poster sprites. Keep 160×160 frames; prefer consistent style to avoid wall clutter.',\n                'cats-spritesheet': 'Random cat sprites. Keep 160×160 frames; clear silhouette improves recognition.',\n                'coffee-machine-v3-grid': 'Static coffee machine asset. Keep 230×230 size and anchor to avoid drift.',\n                'coffee-machine-shadow-v1': 'Coffee machine shadow layer. Align width/anchor with the machine body for grounded feel.',\n                'serverroom-spritesheet': 'Server-room animation sheet. Keep 180×251 frames; avoid over-flickering lights.',\n                'flowers-bloom-v2': 'Random flower sprites. Keep 128×128 frames; align palette with overall office mood.',\n                'guest_anim_1': 'Guest animation set 1 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_2': 'Guest animation set 2 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_3': 'Guest animation set 3 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_4': 'Guest animation set 4 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_5': 'Guest animation set 5 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_6': 'Guest animation set 6 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_role_1': 'Fallback static guest avatar 1. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_2': 'Fallback static guest avatar 2. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_3': 'Fallback static guest avatar 3. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_4': 'Fallback static guest avatar 4. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_5': 'Fallback static guest avatar 5. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_6': 'Fallback static guest avatar 6. Keep design aligned with corresponding guest_anim for smooth fallback.'\n            },\n            ja: {\n                'office_bg_small': 'メイン背景（現在有効）。1280×720（16:9）推奨。部屋構造と視点を維持し、キャラの位置ズレを防いでください。',\n                'office_bg': '旧背景のバックアップ。通常は直接反映されません。office_bg_small と同構図で保持すると復旧しやすいです。',\n                'star-idle-v5': 'メイン待機スプライトシート。256×256 分割とグリッド構成を維持しないと待機アニメが崩れます。',\n                'star-working-spritesheet-grid': 'メイン作業スプライトシート（デスク状態）。300×300 分割を維持し、重心位置を揃えてください。',\n                'sync-animation': '同期状態素材（現在参照中）。256×256 仕様を守ると静止/ガタつきを回避できます。',\n                'sync-animation-v3-grid': '同期スプライトシート（互換用）。256×256 グリッド維持で同期演出を差し替え可能です。',\n                'error-bug-spritesheet-grid': 'エラー状態スプライトシート。220×220 分割を維持し、視認性の高い配色を推奨。',\n                'desk-v3': 'デスク前景レイヤー。キャラとの前後関係に影響するため、比率と視覚アンカーを維持してください。',\n                'desk': '旧デスク素材（互換）。desk-v3 に近いサイズ/アンカーで差し替えると崩れにくいです。',\n                'sofa-idle-v3': 'ソファ静止素材。256×256 と透過背景を維持し、位置ズレを防いでください。',\n                'sofa-shadow-v1': 'ソファ影レイヤー。本体と同座標に重ねると接地感が出ます。',\n                'memo-bg': 'メモパネル背景。文字可読域を確保し、細かすぎる模様は避けてください。',\n                'plants-spritesheet': '植物ランダム素材。160×160 分割を維持すると複数の植物表示を一括更新できます。',\n                'posters-spritesheet': 'ポスターランダム素材。160×160 分割を維持し、壁面の統一感を意識してください。',\n                'cats-spritesheet': '猫ランダム素材。160×160 分割を維持し、シルエットを明確にすると見分けやすいです。',\n                'coffee-machine-v3-grid': 'コーヒーマシン静止素材。230×230 サイズとアンカーを維持してください。',\n                'coffee-machine-shadow-v1': 'コーヒーマシン影レイヤー。本体と幅・アンカーを揃えると接地感が出ます。',\n                'serverroom-spritesheet': 'サーバールームアニメ素材。180×251 分割を維持し、過度な点滅は避けてください。',\n                'flowers-bloom-v2': '花ランダム素材。128×128 分割を維持し、全体の色調と合わせると馴染みます。',\n                'guest_anim_1': '訪客アニメセット 1（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_2': '訪客アニメセット 2（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_3': '訪客アニメセット 3（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_4': '訪客アニメセット 4（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_5': '訪客アニメセット 5（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_6': '訪客アニメセット 6（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_role_1': '訪客静止フォールバック画像 1。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_2': '訪客静止フォールバック画像 2。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_3': '訪客静止フォールバック画像 3。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_4': '訪客静止フォールバック画像 4。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_5': '訪客静止フォールバック画像 5。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_6': '訪客静止フォールバック画像 6。対応する guest_anim とデザインを揃えると切替時に自然です。'\n            }\n        };\n\n        function getAssetHelpText(path) {\n            const stem = toAssetStem(path);\n            const lang = (uiLang || 'zh');\n            const map = ASSET_HELP_TEXT_MAP[lang] || ASSET_HELP_TEXT_MAP.zh;\n            return map[stem] || t('assetHintDefault');\n        }\n\n        function renderSelectedAssetGuidance(path, inScene = null) {\n            const out = document.getElementById('asset-upload-result');\n            if (!out) return;\n            if (!path) { out.innerHTML = ''; return; }\n            const displayName = getAssetDisplayName(path);\n            const line1 = `📌 ${displayName}（${path}）`;\n            const line2 = `💡 ${getAssetHelpText(path)}`;\n            const line3 = (inScene === false) ? `⚠️ ${t('assetHintNotInScene')}` : '';\n            out.innerHTML = [line1, line2, line3]\n                .filter(Boolean)\n                .map(v => `<p class=\"hint-p\">${v}</p>`)\n                .join('');\n        }\n\n        function pathToTextureCandidates(path) {\n            const file = (path || '').split('/').pop() || '';\n            const stem = file.replace(/\\.[^.]+$/, '');\n            const map = {\n                'office_bg_small': 'office_bg',\n                'star-idle-v5': 'star_idle',\n                'sofa-idle-v3': 'sofa_idle',\n                'sofa-shadow-v1': 'sofa_shadow',\n                'plants-spritesheet': 'plants',\n                'posters-spritesheet': 'posters',\n                'coffee-machine-v3-grid': 'coffee_machine',\n                'coffee-machine-shadow-v1': 'coffee_machine_shadow',\n                'serverroom-spritesheet': 'serverroom',\n                'error-bug-spritesheet-grid': 'error_bug',\n                'cats-spritesheet': 'cats',\n                'desk-v3': 'desk_v2',\n                'desk': 'desk',\n                'star-working-spritesheet-grid': 'star_working',\n                'sync-animation-v3-grid': 'sync_anim',\n                'memo-bg': 'memo_bg',\n                'flowers-bloom-v2': 'flowers',\n            };\n            const cands = [];\n            if (map[stem]) cands.push(map[stem]);\n            cands.push(stem.replace(/-/g, '_'));\n            cands.push(stem);\n            return [...new Set(cands)];\n        }\n\n        function getCurrentScene() {\n            if (!game) return null;\n            if (game.children && game.add) return game;\n            if (game.scene && game.scene.scenes && game.scene.scenes.length) return game.scene.scenes[0];\n            return null;\n        }\n\n        function getSceneChildren() {\n            const scene = getCurrentScene();\n            return (scene && scene.children && scene.children.list) ? scene.children.list : [];\n        }\n\n        function resolveAssetPathByTextureKey(key) {\n            if (!key) return null;\n            const keyToStem = {\n                office_bg: 'office_bg_small',\n                star_idle: 'star-idle-v5',\n                sofa_idle: 'sofa-idle-v3',\n                sofa_shadow: 'sofa-shadow-v1',\n                plants: 'plants-spritesheet',\n                posters: 'posters-spritesheet',\n                coffee_machine: 'coffee-machine-v3-grid',\n                coffee_machine_shadow: 'coffee-machine-shadow-v1',\n                serverroom: 'serverroom-spritesheet',\n                error_bug: 'error-bug-spritesheet-grid',\n                cats: 'cats-spritesheet',\n                desk_v2: 'desk-v3',\n                desk: 'desk',\n                star_working: 'star-working-spritesheet-grid',\n                sync_anim: 'sync-animation-v3-grid',\n                memo_bg: 'memo-bg',\n                flowers: 'flowers-bloom-v2',\n            };\n            const stem = keyToStem[key] || key.replace(/_/g, '-');\n            const cands = assetListData.filter(it => (it.path || '').includes(stem + '.'));\n            const extPriority = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.avif'];\n            for (const ext of extPriority) {\n                const hit = cands.find(it => (it.path || '').endsWith(ext));\n                if (hit) return hit.path;\n            }\n            return cands[0]?.path || null;\n        }\n\n        function buildSceneAssetItems() {\n            const children = getSceneChildren();\n            const byKey = new Map();\n            for (const ch of children) {\n                const key = ch && ch.texture && ch.texture.key;\n                if (!key) continue;\n                if (!byKey.has(key)) byKey.set(key, ch);\n            }\n            const items = [];\n            for (const [key, ref] of byKey.entries()) {\n                const path = resolveAssetPathByTextureKey(key);\n                if (!path) continue;\n                const meta = assetListData.find(x => x.path === path) || {};\n                items.push({ id: `k:${key}`, key, path, ref, ext: meta.ext || '', size: meta.size || 0, width: meta.width || null, height: meta.height || null });\n            }\n            sceneAssetItems = items.sort((a, b) => a.key.localeCompare(b.key));\n        }\n\n        function mapAssetPathToSprite(path) {\n            // 背景做特殊映射：即使纹理 key 已变成 office_bg_live_xxx，也能稳定定位到背景对象\n            if ((path || '').includes('office_bg_small.webp') && officeBgSprite) return officeBgSprite;\n\n            const item = sceneAssetItems.find(x => x.path === path && x.ref && x.ref.getBounds);\n            if (item) return item.ref;\n            const cands = pathToTextureCandidates(path);\n            const children = getSceneChildren();\n            for (const ch of children) {\n                const key = ch && ch.texture && ch.texture.key;\n                if (key && cands.includes(key)) return ch;\n            }\n            return null;\n        }\n\n        function highlightSpriteByAssetPath(path) {\n            const hl = document.getElementById('asset-highlight');\n            if (!hl || !game || !game.canvas) return false;\n            const sp = mapAssetPathToSprite(path);\n            if (!sp || !sp.getBounds) {\n                hl.style.display = 'none';\n                return false;\n            }\n            const b = sp.getBounds();\n            const canvasRect = game.canvas.getBoundingClientRect();\n            const scaleX = canvasRect.width / config.width;\n            const scaleY = canvasRect.height / config.height;\n            hl.style.display = 'block';\n            hl.style.left = (canvasRect.left + b.x * scaleX) + 'px';\n            hl.style.top = (canvasRect.top + b.y * scaleY) + 'px';\n            hl.style.width = Math.max(24, b.width * scaleX) + 'px';\n            hl.style.height = Math.max(24, b.height * scaleY) + 'px';\n            return true;\n        }\n\n        function drawSelectionBoxOnScene(path) {\n            const scene = getCurrentScene();\n            if (!scene) return false;\n            const sp = mapAssetPathToSprite(path);\n            if (!sp || !sp.getBounds) {\n                if (selectionBoxGraphics) selectionBoxGraphics.setVisible(false);\n                return false;\n            }\n            if (!selectionBoxGraphics) selectionBoxGraphics = scene.add.graphics();\n            const b = sp.getBounds();\n            selectionBoxGraphics.clear();\n            selectionBoxGraphics.lineStyle(4, 0x22c55e, 1);\n            selectionBoxGraphics.strokeRect(b.x, b.y, b.width, b.height);\n            selectionBoxGraphics.setDepth(999999);\n            selectionBoxGraphics.setVisible(true);\n            return true;\n        }\n\n\n        function getLiveFrameSizeByAssetPath(path) {\n            try {\n                const sprite = mapAssetPathToSprite(path);\n                if (sprite && sprite.frame) {\n                    const w = Number(sprite.frame.width || 0);\n                    const h = Number(sprite.frame.height || 0);\n                    if (w > 0 && h > 0) return { w, h };\n                }\n            } catch (e) {}\n            return null;\n        }\n\n        function saveAssetPositionOverrides() { /* deprecated: backend only */ }\n\n        async function applySavedPositionOverrides() {\n            try {\n                // 优先：后端持久化坐标；回退：后端默认坐标；最后：本地内存覆盖\n                let serverPositions = {};\n                let serverDefaults = {};\n                try {\n                    const res = await fetch('/assets/positions?t=' + Date.now(), { cache: 'no-store' });\n                    const data = await res.json();\n                    if (data && data.ok && data.items) serverPositions = data.items;\n                } catch (e) {}\n                try {\n                    const res2 = await fetch('/assets/defaults?t=' + Date.now(), { cache: 'no-store' });\n                    const data2 = await res2.json();\n                    if (data2 && data2.ok && data2.items) serverDefaults = data2.items;\n                } catch (e) {}\n\n                const children = getSceneChildren();\n                for (const ch of children) {\n                    const texKey = ch?.texture?.key;\n                    if (!texKey) continue;\n\n                    // 先尝试资产路径命中（推荐持久化键，优先级最高）\n                    const assetPath = resolveAssetPathByTextureKey(texKey);\n                    let ov = null;\n                    if (assetPath) {\n                        ov = serverPositions[assetPath] || serverDefaults[assetPath] || assetPositionOverrides[assetPath];\n                    }\n\n                    // 再尝试 textureKey 命中（兼容旧数据）\n                    if (!ov) {\n                        ov = serverPositions[texKey] || serverDefaults[texKey] || assetPositionOverrides[texKey];\n                    }\n\n                    // 最后按 stem 模糊匹配（处理 webp/png 或 live key 差异）\n                    if (!ov) {\n                        const stem = toAssetStem(assetPath || texKey);\n                        const hitKey = Object.keys(serverPositions).find(k => toAssetStem(k) === stem)\n                            || Object.keys(serverDefaults).find(k => toAssetStem(k) === stem)\n                            || Object.keys(assetPositionOverrides).find(k => toAssetStem(k) === stem);\n                        if (hitKey) ov = serverPositions[hitKey] || serverDefaults[hitKey] || assetPositionOverrides[hitKey];\n                    }\n\n                    if (!ov) continue;\n                    const x = Number(ov.x), y = Number(ov.y), sc = Number(ov.scale || 1);\n                    if (Number.isFinite(x) && Number.isFinite(y)) {\n                        ch.x = x;\n                        ch.y = y;\n                        if (Number.isFinite(sc) && sc > 0 && ch.setScale) ch.setScale(sc);\n                    }\n                }\n            } catch (e) {}\n        }\n\n        function clearAssetSelectionUI() {\n            const hl = document.getElementById('asset-highlight');\n            if (hl) hl.style.display = 'none';\n            if (selectionBoxGraphics) selectionBoxGraphics.setVisible(false);\n        }\n\n        function clearAssetSelection(resetInputs = true) {\n            selectedAssetInfo = null;\n            updateActiveAssetItem('');\n            clearAssetSelectionUI();\n            if (ASSET_WINDOW_MODE) notifyMainWindowAssetAction('clear_preview');\n            const out = document.getElementById('asset-upload-result');\n            if (out) out.textContent = '';\n\n            updateAssetConfirmButtonState();\n        }\n\n        function applyScenePreview(path) {\n            const ok = highlightSpriteByAssetPath(path);\n            const ok2 = drawSelectionBoxOnScene(path);\n            return !!(ok && ok2);\n        }\n\n        function updateActiveAssetItem(path) {\n            document.querySelectorAll('#asset-list .asset-item').forEach(el => {\n                const p = el.getAttribute('data-path');\n                el.classList.toggle('active', p === path);\n            });\n        }\n\n        function updateAssetConfirmButtonState() {\n            const btn = document.getElementById('asset-commit-refresh-btn');\n            const btnReset = document.getElementById('asset-reset-default-btn');\n            const btnPrev = document.getElementById('asset-restore-prev-btn');\n            const panel = document.getElementById('asset-upload-panel');\n            const can = !!(selectedAssetInfo && selectedAssetInfo.path);\n            if (panel) panel.classList.toggle('active', can);\n            [btn, btnReset, btnPrev].forEach((b) => {\n                if (!b) return;\n                b.disabled = !can;\n                b.style.opacity = can ? '1' : '.55';\n            });\n        }\n\n        function selectAssetInDrawer(path) {\n            // 二次点击同一资产 = 取消选择\n            if (selectedAssetInfo && selectedAssetInfo.path === path) {\n                clearAssetSelection(true);\n                return;\n            }\n            selectedAssetInfo = assetListData.find(x => x.path === path) || null;\n            updateActiveAssetItem(path);\n            const ok = applyScenePreview(path);\n            if (ASSET_WINDOW_MODE) notifyMainWindowAssetAction('preview_asset', path);\n            renderSelectedAssetGuidance(path, ok);\n            updateAssetConfirmButtonState();\n        }\n\n        function clearAssetThumbTimers() {\n            assetThumbTimers.forEach(t => clearInterval(t));\n            assetThumbTimers = [];\n        }\n\n        function inferSpritesheetFrameMetaByPath(path) {\n            const p = (path || '').toLowerCase();\n            if (!p) return null;\n            // 优先用文件命名约定推断（不写死具体尺寸）\n            if (p.includes('spritesheet') || p.includes('sprite-sheet') || p.includes('sheet') || p.includes('anim') || p.includes('grid')) {\n                return { w: null, h: null };\n            }\n            return null;\n        }\n\n        function getSpritesheetFrameMeta(item) {\n            // 先看命名是否属于精灵表\n            const inferred = inferSpritesheetFrameMetaByPath(item?.path || '');\n            if (!inferred) return null;\n            // 仅返回“是精灵表”的信号，单帧尺寸后续自动推断\n            return { w: null, h: null, isSheet: true };\n        }\n\n        function guessThumbFrameSize(fullW, fullH, path = '') {\n            const p = (path || '').toLowerCase();\n            // 常见核心资产优先用显式提示（避免误判）\n            const hints = [\n                [/star-working-spritesheet-grid\\.webp$/, 300, 300],\n                [/star-idle-v5\\.(webp|png)$/, 256, 256],\n                [/sync-animation-v3-grid\\.webp$/, 256, 256],\n                [/error-bug-spritesheet-grid\\.webp$/, 220, 220],\n                [/flowers-bloom-v2\\.webp$/, 128, 128],\n                [/plants-spritesheet\\.webp$/, 160, 160]\n            ];\n            for (const [re, fw, fh] of hints) {\n                if (re.test(p) && fullW % fw === 0 && fullH % fh === 0) return { fw, fh };\n            }\n\n            // 通用推断：枚举可整除候选，偏好 cols≈8、帧尺寸适中、近似方形\n            const divisors = (n) => {\n                const arr = [];\n                for (let i = 1; i * i <= n; i++) {\n                    if (n % i === 0) {\n                        arr.push(i);\n                        if (i * i !== n) arr.push(n / i);\n                    }\n                }\n                return arr.sort((a, b) => a - b);\n            };\n            const fwCand = divisors(fullW).filter(v => v >= 48 && v <= 512);\n            const fhCand = divisors(fullH).filter(v => v >= 48 && v <= 512);\n            let best = null;\n            for (const fw of fwCand) {\n                for (const fh of fhCand) {\n                    const cols = fullW / fw;\n                    const rows = fullH / fh;\n                    if (!Number.isInteger(cols) || !Number.isInteger(rows)) continue;\n                    const frames = cols * rows;\n                    if (frames <= 1 || cols < 2 || rows < 1) continue;\n                    let score = 0;\n                    if (cols === 8) score += 120;\n                    else if (cols >= 4 && cols <= 10) score += 45;\n                    if (rows >= 1 && rows <= 10) score += 25;\n                    score += Math.min(frames, 120) * 0.8;\n                    score -= Math.abs(fw - fh) * 0.12;\n                    if (fw === fullW || fh === fullH) score -= 80;\n                    if (!best || score > best.score) best = { fw, fh, score };\n                }\n            }\n            return best ? { fw: best.fw, fh: best.fh } : null;\n        }\n\n        function tryAnimateAssetThumb(item) {\n            if (!item) return;\n            const canvas = document.getElementById(`asset-thumb-canvas-${(item.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`);\n            if (!canvas) return;\n            const ctx = canvas.getContext('2d');\n            if (!ctx) return;\n\n            const img = new Image();\n            img.onload = () => {\n                const fullW = img.naturalWidth || img.width;\n                const fullH = img.naturalHeight || img.height;\n                const meta = getSpritesheetFrameMeta(item);\n                if (!meta) return;\n                const guessed = guessThumbFrameSize(fullW, fullH, item?.path || '');\n                if (!guessed) return;\n                const fw = guessed.fw;\n                const fh = guessed.fh;\n\n                // 判断是否可能是精灵表：整图宽高至少是单帧的整数倍，且总帧数>1\n                const cols = Math.floor(fullW / fw);\n                const rows = Math.floor(fullH / fh);\n                const frames = cols * rows;\n                if (cols < 1 || rows < 1 || frames <= 1) return;\n\n                let idx = 0;\n                const draw = () => {\n                    const cx = (idx % cols) * fw;\n                    const cy = Math.floor(idx / cols) * fh;\n                    ctx.clearRect(0, 0, canvas.width, canvas.height);\n                    ctx.imageSmoothingEnabled = false;\n                    ctx.drawImage(img, cx, cy, fw, fh, 0, 0, canvas.width, canvas.height);\n                    idx = (idx + 1) % frames;\n                };\n                draw();\n                const timer = setInterval(draw, 120);\n                assetThumbTimers.push(timer);\n            };\n            img.src = `/static/${item.path}?t=${Date.now()}`;\n        }\n\n        function isAssetHidden(path) {\n            return hiddenAssetPaths.has(path || '');\n        }\n\n        function setAssetVisible(path, visible) {\n            const p = (path || '').trim();\n            if (!p) return;\n            if (visible) hiddenAssetPaths.delete(p);\n            else hiddenAssetPaths.add(p);\n\n            const sp = mapAssetPathToSprite(p);\n            if (sp && sp.setVisible) {\n                sp.setVisible(!!visible);\n            }\n        }\n\n        function toggleAssetVisibility(path, ev) {\n            if (ev && ev.stopPropagation) ev.stopPropagation();\n            const p = (path || '').trim();\n            if (!p) return;\n            const nextVisible = isAssetHidden(p);\n            setAssetVisible(p, nextVisible);\n            if (ASSET_WINDOW_MODE) notifyMainWindowAssetAction('set_visibility', p, { visible: !!nextVisible });\n            renderAssetDrawerList();\n            const out = document.getElementById('asset-upload-result');\n            if (out) out.textContent = nextVisible ? `✅ ${t('assetShow')}：${p}` : `🙈 ${t('assetHide')}：${p}`;\n            if (selectedAssetInfo && selectedAssetInfo.path === p) {\n                if (!nextVisible) clearAssetSelectionUI();\n                else applyScenePreview(p);\n            }\n        }\n\n        function renderAssetDrawerList() {\n            const q = (document.getElementById('asset-search')?.value || '').trim().toLowerCase();\n            const list = document.getElementById('asset-list');\n            if (!list) return;\n\n            // 统一显示后端全部资产（不再区分已加载/全部）\n            const baseRows = assetListData.map(it => ({ ...it, key: '' }));\n\n            const statePriority = [\n                'star-idle-v5.png',\n                'star-working-spritesheet-grid.webp',\n                'sync-animation-v3-grid.webp',\n                'error-bug-spritesheet-grid.webp'\n            ];\n            const assetRank = (path='') => {\n                const p = (path || '').toLowerCase();\n                const idx = statePriority.findIndex(x => p.endsWith(x));\n                if (idx >= 0) return idx; // 0~3: 四个主状态最前\n\n                // 按钮素材最不重要：统一沉到列表末尾\n                if (p.includes('/btn-') || p.includes('btn-') || p.includes('button')) return 1000;\n\n                if (p.includes('guest_anim_')) return 999; // guest 动画靠后\n                return 100;\n            };\n            const rows = baseRows\n                .filter(it => !q || (it.path || '').toLowerCase().includes(q) || (it.key || '').toLowerCase().includes(q))\n                .sort((a,b)=> {\n                    const ra = assetRank(a.path), rb = assetRank(b.path);\n                    if (ra !== rb) return ra - rb;\n                    return (a.path || '').localeCompare(b.path || '');\n                });\n\n            clearAssetThumbTimers();\n\n            if (rows.length === 0) {\n                list.innerHTML = '<div class=\"asset-sub\" style=\"padding:8px\">暂无资产（可点“刷新”重试）</div>';\n                return;\n            }\n\n            list.innerHTML = rows.map(it => {\n                const isActive = ((selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '') === it.path;\n                const reso = (it.width && it.height) ? `${it.width}×${it.height}` : '-';\n                const displayName = getAssetDisplayName(it.path || '');\n                const thumbId = `asset-thumb-canvas-${(it.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`;\n                const hidden = isAssetHidden(it.path);\n                const visEmoji = hidden ? '🙈' : '👀';\n                return `<div class=\"asset-item ${isActive ? 'active' : ''}\" data-path=\"${it.path}\" onclick=\"selectAssetInDrawer('${(it.path || '').replace(/'/g, \"\\\\'\")}')\">\n                    <canvas id=\"${thumbId}\" class=\"asset-thumb\" width=\"56\" height=\"56\"></canvas>\n                    <div class=\"asset-meta\">\n                        <div class=\"asset-path\">${it.path}</div>\n                        <div class=\"asset-sub\">${displayName} ｜ ${reso}${hidden ? ` ｜ ${t('hiddenTag')}` : ''}</div>\n                    </div>\n                    <button class=\"asset-vis-btn\" onclick=\"toggleAssetVisibility('${(it.path || '').replace(/'/g, \"\\\\'\")}', event)\">${visEmoji}</button>\n                </div>`;\n            }).join('');\n\n            // 先画静态缩略图，再尝试对精灵表做逐帧预览\n            rows.forEach(it => {\n                const canvas = document.getElementById(`asset-thumb-canvas-${(it.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`);\n                if (!canvas) return;\n                const ctx = canvas.getContext('2d');\n                if (!ctx) return;\n                const img = new Image();\n                img.onload = () => {\n                    ctx.clearRect(0, 0, canvas.width, canvas.height);\n                    ctx.imageSmoothingEnabled = false;\n                    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n                    tryAnimateAssetThumb(it);\n                };\n                img.src = `/static/${it.path}?t=${Date.now()}`;\n            });\n        }\n\n        async function refreshAssetDrawerList() {\n            const out = document.getElementById('asset-upload-result');\n            try {\n                const selectedPath = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';\n                const res = await fetch('/assets/list?t=' + Date.now(), { cache: 'no-store' });\n                const data = await res.json();\n                assetListData = data.items || [];\n\n                // 场景渲染可能稍晚，做一次延迟抓取\n                buildSceneAssetItems();\n                if (sceneAssetItems.length === 0) {\n                    setTimeout(() => {\n                        buildSceneAssetItems();\n                        renderAssetDrawerList();\n                    }, 500);\n                }\n\n                renderAssetDrawerList();\n                if (out) out.textContent = `${t('assetListLoaded')}：${assetListData.length} ｜ ${t('sceneCaptured')}：${sceneAssetItems.length}`;\n\n                if (selectedPath) {\n                    updateActiveAssetItem(selectedPath);\n                    applyScenePreview(selectedPath);\n                }\n            } catch (e) {\n                console.error('加载资产列表失败', e);\n                if (out) out.textContent = `❌ ${t('assetListLoadFailed')}`;\n            }\n        }\n\n        function bindDrawerFileMeta() {\n            const input = document.getElementById('asset-upload-file');\n            const out = document.getElementById('asset-upload-result');\n            if (!input || !out) return;\n            input.onchange = () => {\n                const f = input.files && input.files[0];\n                const targetPath = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';\n                if (!f) {\n                    if (targetPath) {\n                        const inScene = !!applyScenePreview(targetPath);\n                        renderSelectedAssetGuidance(targetPath, inScene);\n                    } else {\n                        out.textContent = '';\n                    }\n                    updateAssetConfirmButtonState();\n                    return;\n                }\n                const targetLabel = targetPath || '-';\n                const pending = `${t('uploadPending')}：${f.name} ｜ ${formatSizeHuman(f.size)} ｜ ${t('uploadTarget')}：${targetLabel}`;\n                if (targetPath) {\n                    const inScene = !!mapAssetPathToSprite(targetPath);\n                    const displayName = getAssetDisplayName(targetPath);\n                    const hint = getAssetHelpText(targetPath);\n                    const warn = inScene ? '' : `⚠️ ${t('assetHintNotInScene')}`;\n                    out.innerHTML = [\n                        `<p class=\"hint-p\">${pending}</p>`,\n                        `<p class=\"hint-p\">📌 ${displayName}（${targetPath}）</p>`,\n                        `<p class=\"hint-p\">💡 ${hint}</p>`,\n                        warn ? `<p class=\"hint-p\">${warn}</p>` : ''\n                    ].filter(Boolean).join('');\n                } else {\n                    out.innerHTML = `<p class=\"hint-p\">${pending}</p>`;\n                }\n                updateAssetConfirmButtonState();\n            };\n            updateAssetConfirmButtonState();\n        }\n\n        let assetDrawerBackgroundBinded = false;\n        function bindAssetDrawerBackgroundDeselect() {\n            if (assetDrawerBackgroundBinded) return;\n            assetDrawerBackgroundBinded = true;\n            const body = document.getElementById('asset-drawer-body');\n            if (!body) return;\n            body.addEventListener('click', (e) => {\n                if (!assetDrawerOpen || !assetDrawerAuthed) return;\n                // 点击空白处才取消选择；点击控件/资产项不取消\n                const keep = e.target.closest('.asset-item, .asset-toolbar, #asset-upload-panel, #asset-move-panel, button, input, textarea, label, canvas');\n                if (keep) return;\n                clearAssetSelection(true);\n            });\n        }\n\n        let assetDrawerDragInited = false;\n        function initAssetDrawerFloatingWindow() {\n            if (assetDrawerDragInited) return;\n            if (ASSET_WINDOW_MODE) return;\n            const drawer = document.getElementById('asset-drawer');\n            const header = document.getElementById('asset-drawer-header');\n            if (!drawer || !header) return;\n            assetDrawerDragInited = true;\n\n            const centerDrawer = () => {\n                const left = (window.innerWidth - drawer.offsetWidth) / 2;\n                const top = Math.max(14, (window.innerHeight - drawer.offsetHeight) / 2);\n                drawer.style.left = `${left}px`;\n                drawer.style.top = `${top}px`;\n                drawer.style.transform = 'none';\n            };\n\n            let dragging = false;\n            let startX = 0;\n            let startY = 0;\n            let startLeft = 0;\n            let startTop = 0;\n\n            header.addEventListener('pointerdown', (e) => {\n                if (e.button !== 0) return;\n                if (e.target && e.target.closest('button, input, textarea, select, a, [contenteditable]')) return;\n                dragging = true;\n                startX = e.clientX;\n                startY = e.clientY;\n                startLeft = parseFloat(drawer.style.left) || drawer.getBoundingClientRect().left;\n                startTop = parseFloat(drawer.style.top) || drawer.getBoundingClientRect().top;\n                if (typeof header.setPointerCapture === 'function') {\n                    try { header.setPointerCapture(e.pointerId); } catch (_) {}\n                }\n                e.preventDefault();\n            });\n\n            header.addEventListener('pointermove', (e) => {\n                if (!dragging) return;\n                const dx = e.clientX - startX;\n                const dy = e.clientY - startY;\n                drawer.style.left = `${startLeft + dx}px`;\n                drawer.style.top = `${startTop + dy}px`;\n                drawer.style.transform = 'none';\n            });\n\n            const endDrag = (e) => {\n                if (!dragging) return;\n                dragging = false;\n                if (e && typeof header.releasePointerCapture === 'function') {\n                    try { header.releasePointerCapture(e.pointerId); } catch (_) {}\n                }\n            };\n            header.addEventListener('pointerup', endDrag);\n            header.addEventListener('pointercancel', endDrag);\n\n            window.addEventListener('resize', () => {\n                if (!assetDrawerOpen) return;\n                const left = parseFloat(drawer.style.left) || drawer.getBoundingClientRect().left;\n                const top = parseFloat(drawer.style.top) || drawer.getBoundingClientRect().top;\n                drawer.style.left = `${left}px`;\n                drawer.style.top = `${top}px`;\n                drawer.style.transform = 'none';\n            });\n\n            centerDrawer();\n        }\n\n        function openInlineAssetUploader() {\n            const input = document.getElementById('asset-upload-file');\n            if (!input) return;\n            input.click();\n        }\n        async function refreshSceneObjectByAssetPath(path) {\n            const scene = getCurrentScene();\n            if (!scene || !path) return false;\n\n            const sprite = mapAssetPathToSprite(path);\n            if (!sprite || !sprite.texture) return false;\n\n            const oldKey = sprite.texture.key;\n            const ext = path.split('.').pop();\n            const newKey = `${oldKey}_live_${Date.now()}`;\n            const url = `/static/${path}?t=${Date.now()}`;\n\n            return new Promise((resolve) => {\n                try {\n                    scene.load.once('complete', () => {\n                        try {\n                            // 替换到新纹理\n                            if (sprite.setTexture) sprite.setTexture(newKey);\n                            // 同 key 角色（如多个同材质装饰）一起替换\n                            getSceneChildren().forEach(ch => {\n                                if (ch !== sprite && ch.texture && ch.texture.key === oldKey && ch.setTexture) {\n                                    ch.setTexture(newKey);\n                                }\n                            });\n                            // 更新背景引用\n                            if (oldKey === 'office_bg' && officeBgSprite && officeBgSprite.texture && officeBgSprite.texture.key === newKey) {\n                                currentOfficeBgTextureKey = newKey;\n                            }\n                            // 移除旧纹理，避免内存堆积\n                            if (oldKey !== newKey && scene.textures.exists(oldKey)) {\n                                scene.textures.remove(oldKey);\n                            }\n                            resolve(true);\n                        } catch (e) {\n                            console.warn('替换场景纹理失败(setTexture):', e);\n                            resolve(false);\n                        }\n                    });\n                    scene.load.once('loaderror', () => resolve(false));\n\n                    // 按扩展名用对应 loader\n                    if (ext === 'json') {\n                        resolve(false);\n                        return;\n                    }\n                    scene.load.image(newKey, url);\n                    scene.load.start();\n                } catch (e) {\n                    console.warn('替换场景纹理失败(load):', e);\n                    resolve(false);\n                }\n            });\n        }\n\n        async function commitAssetUpdate() {\n            const path = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';\n            const fi = document.getElementById('asset-upload-file');\n            const out = document.getElementById('asset-upload-result');\n            if (!path) { out.textContent = '请先选中一个资产路径'; return false; }\n            if (!fi.files.length) { return true; } // 允许仅改坐标\n            const file = fi.files[0];\n            const fd = new FormData();\n            fd.append('path', path);\n            fd.append('backup', '1');\n            fd.append('file', file);\n\n            const nameLower = (file.name || '').toLowerCase();\n            const isAnimInput = nameLower.endsWith('.gif') || nameLower.endsWith('.webp');\n            const isSheetTarget = !!inferSpritesheetFrameMetaByPath(path);\n\n            if (isSheetTarget) {\n                fd.append('auto_spritesheet', '1');\n                // 全自动：后端识别并切帧\n                if (isAnimInput) {\n                    fd.append('preserve_original', '1');\n                } else {\n                    // 静态图兜底切法\n                    fd.append('frame_w', '64');\n                    fd.append('frame_h', '64');\n                    fd.append('preserve_original', '0');\n                }\n                fd.append('pixel_art', '1');\n            }\n\n            out.textContent = '⏳ 正在上传并替换，请稍候...';\n            const res = await fetch('/assets/upload', { method: 'POST', body: fd });\n            const data = await res.json();\n            if (!data.ok) {\n                out.textContent = `❌ 更新失败：${data.msg || res.status}`;\n                return false;\n            }\n\n            if (data.converted) {\n                const toType = data.converted.to || 'spritesheet';\n                out.textContent = `✅ 已上传（动图→${toType}）：${data.path} ｜ ${data.converted.frames}帧 ${data.converted.frame_w}x${data.converted.frame_h}`;\n            } else {\n                out.textContent = `✅ 已上传：${data.path}`;\n            }\n            return true;\n        }\n\n        async function commitAndRefresh() {\n            const out = document.getElementById('asset-upload-result');\n            const fi = document.getElementById('asset-upload-file');\n            const hasFile = !!(fi && fi.files && fi.files.length > 0);\n            const path = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';\n\n            const okUpload = await commitAssetUpdate();\n            if (!okUpload) return;\n\n            if (out) {\n                if (hasFile) out.textContent += ' ｜ ✅ 已上传并刷新';\n                else out.textContent = '✅ 已确认并刷新';\n            }\n\n            // 刷新前关闭侧边栏，行为与地图替换一致\n            assetDrawerOpen = false;\n            const drawer = document.getElementById('asset-drawer');\n            if (drawer) drawer.classList.remove('open');\n            const backdrop = document.getElementById('asset-drawer-backdrop');\n            if (backdrop) backdrop.classList.remove('open');\n            if (path) await notifyMainWindowAssetRefresh('asset_path', path);\n\n            setTimeout(() => window.location.reload(), 400);\n        }\n\nfunction toggleBrokerPanel() {\n            const btn = document.querySelector('#asset-broker-row .btn-broker');\n            flashButtonActive(btn);\n            const p = document.getElementById('asset-broker-panel');\n            if (!p) return;\n            p.classList.toggle('open');\n        }\n\n        function toggleManualPanel() {\n            const btn = document.querySelector('#asset-broker-row .btn-diy');\n            flashButtonActive(btn);\n            assetManualPanelOpen = !assetManualPanelOpen;\n            updateManualPanelUI();\n        }\n\n        function placeOverlayAndStatusAtCanvasBottomLeft() {\n            const canvasEl = game?.canvas || document.querySelector('#game-container canvas');\n            const fallbackBox = document.getElementById('game-container');\n            const rect = canvasEl?.getBoundingClientRect?.() || fallbackBox?.getBoundingClientRect?.();\n\n            // 1) loading 遮罩\n            const overlay = document.getElementById('room-loading-overlay');\n            if (overlay) {\n                if (!rect || !(rect.width > 0 && rect.height > 0)) {\n                    overlay.style.left = '0px';\n                    overlay.style.top = '0px';\n                    overlay.style.width = window.innerWidth + 'px';\n                    overlay.style.height = window.innerHeight + 'px';\n                } else {\n                    overlay.style.left = rect.left + 'px';\n                    overlay.style.top = rect.top + 'px';\n                    overlay.style.width = rect.width + 'px';\n                    overlay.style.height = rect.height + 'px';\n                }\n            }\n\n            // 2) detail/status 严格限制在画布内部左下角\n            const st = document.getElementById('status-text');\n            const gameContainer = document.getElementById('game-container');\n            if (st && gameContainer) {\n                if (rect && rect.width > 0 && rect.height > 0) {\n                    const localLeft = Math.max(8, Math.round(rect.left - gameContainer.getBoundingClientRect().left + 14));\n                    const localBottom = 14;\n                    st.style.left = localLeft + 'px';\n                    st.style.bottom = localBottom + 'px';\n                    st.style.maxWidth = Math.max(120, Math.floor(rect.width - 28)) + 'px';\n                } else {\n                    st.style.left = '14px';\n                    st.style.bottom = '14px';\n                    st.style.maxWidth = 'calc(100% - 28px)';\n                }\n            }\n        }\n\n        function showRoomLoadingOverlay(baseText) {\n            const overlay = document.getElementById('room-loading-overlay');\n            const textEl = document.getElementById('room-loading-text');\n            const emojiEl = document.getElementById('room-loading-emoji');\n            if (!overlay || !textEl || !emojiEl) return;\n\n            placeOverlayAndStatusAtCanvasBottomLeft();\n            const loadingTexts = {\n                zh: [\n                    '正在打包今天的灵感行李……',\n                    '正在抽取下一站数字坐标……',\n                    '正在查看本次漂流目的地……',\n                    '正在把办公室折叠成随身模式……',\n                    '正在给钳子装上远行 Buff……',\n                    '正在匹配下一段创作气候……',\n                    '正在把时差调成冒险模式……',\n                    '正在接收陌生街区的 Wi‑Fi 心跳……',\n                    '正在试播下一站的海风 BGM……',\n                    '正在加载“也许会爱上”的新房间……',\n                    '正在为未知邻居准备自我介绍……',\n                    '正在解锁下一片数字海域……',\n                    '正在把好奇心调到满格……',\n                    '正在等待旅程投递下一张门牌号……'\n                ],\n                en: [\n                    'Packing today’s luggage of inspiration…',\n                    'Drawing the digital coordinates for the next stop…',\n                    'Checking the destination of this drift…',\n                    'Folding the office into portable mode…',\n                    'Installing a travel buff on the claws…',\n                    'Matching the creative climate for the next chapter…',\n                    'Switching the time zone to adventure mode…',\n                    'Receiving Wi‑Fi heartbeats from an unfamiliar block…',\n                    'Previewing the sea-breeze BGM of the next stop…',\n                    'Loading a new room you might just fall in love with…',\n                    'Preparing an intro for unknown neighbors…',\n                    'Unlocking the next digital sea…',\n                    'Turning curiosity up to max…',\n                    'Waiting for the journey to deliver the next door number…'\n                ],\n                ja: [\n                    '今日のひらめき荷物を梱包しています……',\n                    '次の目的地のデジタル座標を抽出しています……',\n                    '今回の漂流先を確認しています……',\n                    'オフィスを携帯モードに折りたたんでいます……',\n                    'ハサミに遠征 Buff を装着しています……',\n                    '次の創作区間の気候をマッチングしています……',\n                    '時差を冒険モードに切り替えています……',\n                    '見知らぬ街区の Wi‑Fi ハートビートを受信しています……',\n                    '次の目的地の潮風 BGM を試聴しています……',\n                    '「好きになるかもしれない」新しい部屋を読み込んでいます……',\n                    '未知のご近所さん向けに自己紹介を準備しています……',\n                    '次のデジタル海域をアンロックしています……',\n                    '好奇心を最大値まで上げています……',\n                    '旅が次の番地を届けるのを待っています……'\n                ]\n            };\n            const steps = loadingTexts[uiLang] || loadingTexts.zh;\n            const emojis = ['🦞','🦀','🦐','🦑','🐙','🐟','🐠','🐡','🦪','🍣','🍤','🍱','🍲','🍜','🍝','🌊','🐚','🪸'];\n\n            roomLoadingIndex = 0;\n            roomLoadingEmojiIndex = 0;\n            textEl.textContent = baseText || steps[0];\n            emojiEl.textContent = emojis[0];\n            overlay.style.display = 'flex';\n            if (roomLoadingTimer) clearInterval(roomLoadingTimer);\n            roomLoadingTimer = setInterval(() => {\n                roomLoadingIndex = (roomLoadingIndex + 1) % steps.length;\n                roomLoadingEmojiIndex = (roomLoadingEmojiIndex + 1) % emojis.length;\n                textEl.textContent = steps[roomLoadingIndex];\n                emojiEl.textContent = emojis[roomLoadingEmojiIndex];\n            }, 900);\n        }\n\n        function hideRoomLoadingOverlay() {\n            const overlay = document.getElementById('room-loading-overlay');\n            if (roomLoadingTimer) {\n                clearInterval(roomLoadingTimer);\n                roomLoadingTimer = null;\n            }\n            if (overlay) overlay.style.display = 'none';\n        }\n\n        async function refreshOfficeBackgroundOnly() {\n            return await refreshSceneObjectByAssetPath('office_bg_small.webp');\n        }\n        async function notifyMainWindowAssetRefresh(kind, path = '') {\n            if (!ASSET_WINDOW_MODE || !ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return;\n            try {\n                await window.__TAURI__.core.invoke('notify_main_window_asset_refresh', { kind, path });\n            } catch (_) {}\n        }\n        async function notifyMainWindowAssetAction(action, path = '', extra = {}) {\n            if (!ASSET_WINDOW_MODE || !ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return;\n            try {\n                await window.__TAURI__.core.invoke('notify_main_window_asset_refresh', {\n                    kind: 'asset_action',\n                    action,\n                    path,\n                    ...extra\n                });\n            } catch (_) {}\n        }\n\n        function markMoveSuccess(outEl, btnEl = null) {\n            if (outEl) outEl.textContent = t('moveSuccess');\n            if (btnEl) setButtonDone(btnEl);\n            try { setState('idle', t('moveSuccess').replace('✅ ', '')); } catch (e) {}\n        }\n\n        function setWorkingStatus(detail = t('stateDetailWriting')) {\n            try { setState('writing', detail); } catch (e) {}\n        }\n\n        async function ensureGeminiConfigLoaded() {\n            try {\n                const authRes = await fetch('/assets/auth/status', { cache: 'no-store' });\n                const authData = await authRes.json();\n                assetDrawerAuthed = !!(authData && authData.ok && authData.authed);\n                updateAssetAuthUI();\n                if (!assetDrawerAuthed) return;\n\n                const res = await fetch('/config/gemini', { cache: 'no-store' });\n                const data = await res.json();\n                if (data && data.ok) {\n                    window.geminiConfig = {\n                        hasKey: !!data.has_api_key,\n                        model: data.gemini_model || 'nanobanana-pro'\n                    };\n                    const box = document.getElementById('asset-gemini-config');\n                    if (box) box.style.display = 'block';\n                    const ms = document.getElementById('gemini-mask-status');\n                    if (ms) {\n                        ms.textContent = data.has_api_key\n                            ? `${t('geminiMaskHasKey')} ${data.api_key_masked || ''}`\n                            : t('geminiMaskNoKey');\n                    }\n                }\n            } catch (e) {}\n        }\n\n        async function saveGeminiConfigFromUI() {\n            const input = document.getElementById('gemini-api-key-input');\n            const msg = document.getElementById('gemini-config-msg');\n            const key = (input?.value || '').trim();\n            if (!key) {\n                if (msg) msg.textContent = '请输入有效 API Key';\n                return;\n            }\n            try {\n                const res = await fetch('/config/gemini', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ api_key: key, model: 'nanobanana-pro' })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (msg) msg.textContent = `保存失败：${data.msg || res.status}`;\n                    return;\n                }\n                if (msg) msg.textContent = '✅ 已保存，可重新点击搬家/中介';\n                const box = document.getElementById('asset-gemini-config');\n                if (box) box.style.display = 'none';\n                await ensureGeminiConfigLoaded();\n            } catch (e) {\n                if (msg) msg.textContent = `保存失败：${e}`;\n            }\n        }\n\n        function flashButtonActive(el, ms = 180) {\n            if (!el) return;\n            el.classList.add('is-active');\n            setTimeout(() => el.classList.remove('is-active'), ms);\n        }\n\n        function setButtonDone(el, holdMs = 1200) {\n            if (!el) return;\n            el.classList.remove('is-active');\n            el.classList.add('is-done');\n            setTimeout(() => el.classList.remove('is-done'), holdMs);\n        }\n\n        async function generateCustomRpgBackground() {\n            const brokerBtn = document.querySelector('#asset-broker-row .btn-broker');\n            flashButtonActive(brokerBtn);\n            setWorkingStatus(t('statusBrokerDecorating'));\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            const prompt = (document.getElementById('asset-broker-prompt')?.value || '').trim();\n            if (!prompt) {\n                out.textContent = t('brokerNeedPrompt');\n                return;\n            }\n            // 点击即刻显示遮罩，先于任何网络调用\n            showRoomLoadingOverlay();\n            out.textContent = t('brokerGenerating');\n            try {\n                const res = await fetch('/assets/generate-rpg-background', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ prompt, speed_mode: speedMode })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (data.code === 'MISSING_API_KEY') {\n                        out.textContent = t('brokerMissingKey');\n                        const box = document.getElementById('asset-gemini-config');\n                        if (box) box.style.display = 'block';\n                    } else if (data.code === 'API_KEY_REVOKED_OR_LEAKED') {\n                        out.textContent = '❌ 当前 API Key 已失效/疑似泄露，请更换新 Key 后重试';\n                        const box = document.getElementById('asset-gemini-config');\n                        if (box) box.style.display = 'block';\n                    } else if (data.code === 'MODEL_NOT_AVAILABLE') {\n                        out.textContent = '❌ 当前模型在此通道不可用，请切换可用模型后重试';\n                    } else {\n                        out.textContent = `❌ 生成失败：${data.msg || res.status}`;\n                    }\n                    return;\n                }\n                out.textContent = t('brokerDone');\n                const ok = await refreshOfficeBackgroundOnly();\n                await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp');\n                if (ok) {\n                    markMoveSuccess(out, brokerBtn);\n                } else {\n                    out.textContent = '✅ 已生成并替换底图（局部刷新失败，可手动刷新页面）';\n                }\n            } catch (e) {\n                out.textContent = `❌ 生成失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function generateRpgBackground() {\n            const moveBtn = document.getElementById('btn-move-house');\n            flashButtonActive(moveBtn);\n            setWorkingStatus(t('statusMovingHome'));\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            // 点击即刻显示遮罩，先于任何网络调用\n            showRoomLoadingOverlay();\n            out.textContent = '🧳 正在打包行李，请稍后（约30~60秒）';\n            try {\n                const res = await fetch('/assets/generate-rpg-background', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ speed_mode: speedMode })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (data.code === 'MISSING_API_KEY') {\n                        out.textContent = t('brokerMissingKey');\n                        const box = document.getElementById('asset-gemini-config');\n                        if (box) box.style.display = 'block';\n                    } else if (data.code === 'API_KEY_REVOKED_OR_LEAKED') {\n                        out.textContent = '❌ 当前 API Key 已失效/疑似泄露，请更换新 Key 后重试';\n                        const box = document.getElementById('asset-gemini-config');\n                        if (box) box.style.display = 'block';\n                    } else if (data.code === 'MODEL_NOT_AVAILABLE') {\n                        out.textContent = '❌ 当前模型在此通道不可用，请切换可用模型后重试';\n                    } else {\n                        out.textContent = `❌ 生成失败：${data.msg || res.status}`;\n                    }\n                    return;\n                }\n                out.textContent = '✅ 已生成并替换底图，正在刷新房间...';\n                const ok = await refreshOfficeBackgroundOnly();\n                await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp');\n                if (ok) {\n                    markMoveSuccess(out, moveBtn);\n                } else {\n                    out.textContent = '✅ 已生成并替换底图（局部刷新失败，可手动刷新页面）';\n                }\n            } catch (e) {\n                out.textContent = `❌ 生成失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function restoreHomeBackground() {\n            const homeBtn = document.getElementById('btn-back-home');\n            flashButtonActive(homeBtn);\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n\n            const confirmMsg = '⚠️ 回老家会覆盖当前自定义房间背景（可从 bg-history 恢复历史图）。\\n确定继续吗？';\n            if (!window.confirm(confirmMsg)) {\n                out.textContent = '已取消回老家';\n                return;\n            }\n\n            setWorkingStatus(t('statusRestoreHome'));\n            // 点击即刻显示遮罩，先于任何网络调用\n            showRoomLoadingOverlay();\n            out.textContent = '🏡 正在回老家（恢复初始底图）...';\n            try {\n                const res = await fetch('/assets/restore-reference-background', { method: 'POST' });\n                const data = await res.json();\n                if (!data.ok) {\n                    out.textContent = `❌ 恢复失败：${data.msg || res.status}`;\n                    return;\n                }\n                out.textContent = '✅ 已恢复初始底图';\n                const ok = await refreshOfficeBackgroundOnly();\n                await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp');\n                if (ok) {\n                    markMoveSuccess(out, homeBtn);\n                } else {\n                    out.textContent = '✅ 已恢复初始底图（局部刷新失败，可手动刷新页面）';\n                }\n            } catch (e) {\n                out.textContent = `❌ 恢复失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function restoreLastGeneratedBackground() {\n            const btn = document.getElementById('btn-back-last-bg');\n            flashButtonActive(btn);\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n\n            const confirmMsg = '⚠️ 将回退到最近一次生成的房间背景，确定继续吗？';\n            if (!window.confirm(confirmMsg)) {\n                out.textContent = '已取消回退';\n                return;\n            }\n\n            setWorkingStatus(t('statusRestoreLastBg'));\n            showRoomLoadingOverlay();\n            out.textContent = '↩️ 正在回退到最近一次生成底图...';\n            try {\n                const res = await fetch('/assets/restore-last-generated-background', { method: 'POST' });\n                const data = await res.json();\n                if (!data.ok) {\n                    out.textContent = `❌ 回退失败：${data.msg || res.status}`;\n                    return;\n                }\n                const ok = await refreshOfficeBackgroundOnly();\n                await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp');\n                if (ok) {\n                    out.textContent = '✅ 已回退到上一次背景';\n                } else {\n                    out.textContent = '✅ 已回退到上一次背景（局部刷新失败，可手动刷新页面）';\n                }\n                try { setState('idle', '已回退到上一次背景'); } catch (e) {}\n            } catch (e) {\n                out.textContent = `❌ 回退失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function fetchJsonSafe(url, options = {}) {\n            const res = await fetch(url, options);\n            const ct = (res.headers.get('content-type') || '').toLowerCase();\n            if (!ct.includes('application/json')) {\n                const txt = await res.text();\n                const brief = (txt || '').replace(/\\s+/g, ' ').slice(0, 120);\n                throw new Error(`接口未返回 JSON（${res.status}）: ${brief || 'empty response'}`);\n            }\n            return await res.json();\n        }\n\n        async function renderHomeFavorites(force = false) {\n            const box = document.getElementById('asset-home-favorites-list');\n            if (!box) return;\n            const now = Date.now();\n            if (!force && homeFavoritesCache.length > 0 && (now - homeFavoritesLoadedAt) < 30000) {\n                // 使用缓存，避免频繁请求\n            } else {\n                try {\n                    const data = await fetchJsonSafe('/assets/home-favorites/list', { cache: 'no-store' });\n                    if (data && data.ok && Array.isArray(data.items)) {\n                        homeFavoritesCache = data.items;\n                        homeFavoritesLoadedAt = now;\n                    }\n                } catch (e) {\n                    const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n                    if (out) out.textContent = `❌ 收藏列表加载失败：${e.message || e}`;\n                }\n            }\n\n            if (!homeFavoritesCache.length) {\n                box.innerHTML = `<div class=\"asset-sub\" style=\"padding:4px 2px;\">${t('homeFavEmpty')}</div>`;\n                return;\n            }\n\n            box.innerHTML = homeFavoritesCache.map((it) => {\n                const id = (it.id || '').replace(/'/g, \"\\\\'\");\n                const thumb = it.thumb_url || it.url || '';\n                const time = it.created_at || '';\n                return `<div class=\"home-fav-item\">\n                    <img src=\"${thumb}\" loading=\"lazy\" alt=\"favorite-home\" />\n                    <div class=\"home-fav-meta\">${time}</div>\n                    <button onclick=\"applyHomeFavorite('${id}')\">${t('homeFavApply')}</button>\n                </div>`;\n            }).join('');\n        }\n\n        async function saveCurrentHomeFavorite() {\n            const btn = document.getElementById('btn-favorite-home');\n            flashButtonActive(btn);\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            try {\n                const data = await fetchJsonSafe('/assets/home-favorites/save-current', { method: 'POST' });\n                if (!data.ok) {\n                    out.textContent = `❌ 收藏失败：${data.msg || 'unknown error'}`;\n                    return;\n                }\n                out.textContent = t('homeFavSaved');\n                await renderHomeFavorites(true);\n            } catch (e) {\n                out.textContent = `❌ 收藏失败：${e.message || e}`;\n            }\n        }\n\n        async function applyHomeFavorite(id) {\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            if (!id) return;\n            showRoomLoadingOverlay();\n            setWorkingStatus(t('statusApplyFavorite'));\n            try {\n                const data = await fetchJsonSafe('/assets/home-favorites/apply', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ id })\n                });\n                if (!data.ok) {\n                    out.textContent = `❌ 替换失败：${data.msg || 'unknown error'}`;\n                    return;\n                }\n                const ok = await refreshOfficeBackgroundOnly();\n                await notifyMainWindowAssetRefresh('office_bg', 'office_bg_small.webp');\n                out.textContent = ok ? t('homeFavApplied') : `${t('homeFavApplied')}（局部刷新失败，可手动刷新页面）`;\n                try { setState('idle', '已应用收藏地图'); } catch (e) {}\n            } catch (e) {\n                out.textContent = `❌ 替换失败：${e.message || e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function resetSelectedAssetToDefault() {\n            const out = document.getElementById('asset-upload-result');\n            const path = selectedAssetInfo && selectedAssetInfo.path;\n            if (!path) {\n                if (out) out.textContent = '请先选择一个资产';\n                return;\n            }\n            if (!window.confirm(`⚠️ 确定将 ${path} 重置为默认资产吗？`)) return;\n            try {\n                const res = await fetch('/assets/restore-default', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ path })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (out) out.textContent = `❌ 重置失败：${data.msg || res.status}`;\n                    return;\n                }\n                await refreshSceneObjectByAssetPath(path);\n                await notifyMainWindowAssetRefresh('asset_path', path);\n                if (out) out.textContent = `✅ 已重置为默认资产：${path}`;\n            } catch (e) {\n                if (out) out.textContent = `❌ 重置失败：${e}`;\n            }\n        }\n\n        async function restoreSelectedAssetPrev() {\n            const out = document.getElementById('asset-upload-result');\n            const path = selectedAssetInfo && selectedAssetInfo.path;\n            if (!path) {\n                if (out) out.textContent = '请先选择一个资产';\n                return;\n            }\n            if (!window.confirm(`⚠️ 确定将 ${path} 回退到上一版吗？`)) return;\n            try {\n                const res = await fetch('/assets/restore-prev', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ path })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (out) out.textContent = `❌ 回退失败：${data.msg || res.status}`;\n                    return;\n                }\n                await refreshSceneObjectByAssetPath(path);\n                await notifyMainWindowAssetRefresh('asset_path', path);\n                if (out) out.textContent = `✅ 已回退到上一版：${path}`;\n            } catch (e) {\n                if (out) out.textContent = `❌ 回退失败：${e}`;\n            }\n        }\n\n        async function openAssetWindowFromMain() {\n            if (ASSET_WINDOW_MODE) return;\n            if (!ELECTRON_MODE || !window.__TAURI__ || !window.__TAURI__.core) return;\n            try {\n                await window.__TAURI__.core.invoke('open_asset_window');\n            } catch (_) {\n                // ignore and allow retry\n            }\n        }\n\n        async function toggleAssetDrawer(force) {\n            const drawer = document.getElementById('asset-drawer');\n            const backdrop = document.getElementById('asset-drawer-backdrop');\n\n            if (!ASSET_WINDOW_MODE && ELECTRON_MODE) {\n                // Electron main window never toggles in-page drawer anymore.\n                // Keep state closed to avoid any open/close flicker, and always use dedicated asset window.\n                assetDrawerOpen = false;\n                if (drawer) drawer.classList.remove('open');\n                if (backdrop) backdrop.classList.remove('open');\n                document.body.classList.remove('drawer-open');\n\n                if (force === false) return;\n                try {\n                    if (window.__TAURI__ && window.__TAURI__.core) {\n                        await window.__TAURI__.core.invoke('open_asset_window');\n                    }\n                } catch (_) {}\n                return;\n            }\n\n            if (ASSET_WINDOW_MODE) {\n                if (force === false) {\n                    try {\n                        if (window.__TAURI__ && window.__TAURI__.core) {\n                            await window.__TAURI__.core.invoke('close_asset_window');\n                        } else {\n                            window.close();\n                        }\n                    } catch (_) {\n                        window.close();\n                    }\n                    return;\n                }\n\n                assetDrawerOpen = true;\n                if (drawer) drawer.classList.add('open');\n                if (backdrop) backdrop.classList.remove('open');\n                assetManualPanelOpen = false;\n                updateAssetAuthUI();\n                bindAssetDrawerBackgroundDeselect();\n                await ensureGeminiConfigLoaded();\n                if (assetDrawerAuthed) {\n                    await applySavedPositionOverrides();\n                    await refreshAssetDrawerList();\n                    await renderHomeFavorites(false);\n                    bindDrawerFileMeta();\n                } else {\n                    const msg = document.getElementById('asset-auth-msg');\n                    if (msg) msg.textContent = t('authDefaultPassHint');\n                }\n                return;\n            }\n\n            const next = (typeof force === 'boolean') ? force : !assetDrawerOpen;\n            assetDrawerOpen = next;\n            drawer.classList.toggle('open', next);\n            if (backdrop) backdrop.classList.toggle('open', next);\n            document.body.classList.remove('drawer-open');\n\n            const openBtn = document.getElementById('btn-open-drawer');\n            if (openBtn) {\n                openBtn.classList.toggle('is-active', next);\n                openBtn.textContent = t('btnDecor');\n            }\n            const closeBtn = document.getElementById('btn-close-drawer');\n            if (closeBtn) closeBtn.textContent = t('drawerClose');\n            if (next) {\n                initAssetDrawerFloatingWindow();\n                assetManualPanelOpen = false;\n                updateAssetAuthUI();\n                bindAssetDrawerBackgroundDeselect();\n                await ensureGeminiConfigLoaded();\n                if (assetDrawerAuthed) {\n                    await applySavedPositionOverrides();\n                    await refreshAssetDrawerList();\n                    await renderHomeFavorites(false);\n                    bindDrawerFileMeta();\n                } else {\n                    const msg = document.getElementById('asset-auth-msg');\n                    if (msg) msg.textContent = t('authDefaultPassHint');\n                }\n            } else {\n                assetManualPanelOpen = false;\n                updateManualPanelUI();\n                clearAssetSelectionUI();\n            }\n        }\n\n        // Guest Agent 离开房间\n        function removeGuestSpriteByName(name) {\n            const target = guestAgents.find(a => (a.name || '') === name);\n            if (target && guestSprites[target.agentId]) {\n                guestSprites[target.agentId].sprite.destroy();\n                guestSprites[target.agentId].nameText.destroy();\n                delete guestSprites[target.agentId];\n            }\n            if (target && guestBubbles[target.agentId]) {\n                guestBubbles[target.agentId].destroy();\n                delete guestBubbles[target.agentId];\n            }\n        }\n\n        function leaveGuestAgent(agentId, name) {\n            fetch('/leave-agent', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ agentId, name })\n            }).then(response => response.json()).then(data => {\n                if (data.ok) {\n                    // 优先按 agentId 清理，避免重名误伤\n                    if (agentId && guestSprites[agentId]) {\n                        guestSprites[agentId].sprite.destroy();\n                        guestSprites[agentId].nameText.destroy();\n                        delete guestSprites[agentId];\n                    }\n                    if (agentId && guestBubbles[agentId]) {\n                        guestBubbles[agentId].destroy();\n                        delete guestBubbles[agentId];\n                    }\n                    fetchGuestAgents();\n                    alert((name || agentId) + ' 已离开房间');\n                } else {\n                    // demo agent 没在后端也允许本地隐藏\n                    if (DEMO_MODE && (name === '尼卡' || name === '水星')) {\n                        hiddenDemoNames.add(name);\n                        removeGuestSpriteByName(name);\n                        renderGuestAgentList();\n                        alert(name + ' 已离开房间（demo）');\n                        return;\n                    }\n                    alert('离开失败：' + (data.msg || '未知错误'));\n                }\n            }).catch(error => {\n                alert('请求失败：' + error);\n            });\n        }\n\n        function approveGuestAgent(agentId) {\n            fetch('/agent-approve', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ agentId })\n            }).then(response => response.json()).then(data => {\n                if (data.ok) {\n                    fetchGuestAgents();\n                    alert('已批准该访客接入');\n                } else {\n                    alert('批准失败：' + (data.msg || '未知错误'));\n                }\n            }).catch(error => {\n                alert('请求失败：' + error);\n            });\n        }\n\n        function rejectGuestAgent(agentId) {\n            fetch('/agent-reject', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ agentId })\n            }).then(response => response.json()).then(data => {\n                if (data.ok) {\n                    fetchGuestAgents();\n                    alert('已拒绝该访客');\n                } else {\n                    alert('拒绝失败：' + (data.msg || '未知错误'));\n                }\n            }).catch(error => {\n                alert('请求失败：' + error);\n            });\n        }\n\n        function ensureDemoVisitors() {\n            if (!DEMO_MODE) return;\n            if (!Array.isArray(window.__demoVisitors) || window.__demoVisitors.length === 0) {\n                window.__demoVisitors = [\n                    { agentId: 'demo_nika', name: '尼卡', authStatus: 'approved', state: 'writing', bubbleText: '我在工作中', isDemo: true, updated_at: new Date().toISOString() },\n                    { agentId: 'demo_mercury', name: '水星', authStatus: 'approved', state: 'idle', bubbleText: '我去休息区躺一下', isDemo: true, updated_at: new Date().toISOString() }\n                ];\n            }\n        }\n\n        function getMergedVisitors() {\n            const realVisitors = (guestAgents || []).filter(a => !a.isMain);\n            if (!DEMO_MODE) return realVisitors;\n\n            ensureDemoVisitors();\n            const demoVisitors = window.__demoVisitors.filter(v => !hiddenDemoNames.has(v.name));\n            return [...realVisitors, ...demoVisitors];\n        }\n\n        function renderGuestAgentList() {\n            const list = document.getElementById('guest-agent-list');\n            if (!list) return;\n\n            const visitors = getMergedVisitors();\n            if (visitors.length === 0) {\n                list.innerHTML = '<div style=\"color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;\">暂无访客</div>';\n                return;\n            }\n\n            list.innerHTML = visitors.map(agent => {\n                const name = agent.name || '未命名访客';\n                const authStatus = agent.authStatus || 'pending';\n                const state = agent.state || 'idle';\n                const statusMap = {\n                    approved: '已授权',\n                    pending: '待授权',\n                    rejected: '已拒绝',\n                    offline: '离线'\n                };\n                const stateMap = {\n                    idle: '待命',\n                    writing: '工作',\n                    researching: '调研',\n                    executing: '执行',\n                    syncing: '同步',\n                    error: '报警'\n                };\n\n                const statusText = statusMap[authStatus] || authStatus;\n                const stateText = stateMap[state] || state;\n                const subtitle = `${statusText} · ${stateText}`;\n\n                const pendingActions = `<button onclick=\"alert('交换 skill 功能开发中')\">交换skill</button><button class=\"leave-btn\" onclick=\"leaveGuestAgent('${agent.agentId}','${name}')\">离开房间</button>`;\n\n                return `\n                  <div class=\"guest-agent-item\" data-name=\"${name}\">\n                    <div>\n                      <div class=\"guest-agent-name\">${name}</div>\n                      <div style=\"font-size:11px;color:#cbd5e1;\">${subtitle}</div>\n                    </div>\n                    <div class=\"guest-agent-buttons\">\n                      ${pendingActions}\n                    </div>\n                  </div>\n                `;\n            }).join('');\n        }\n\n        function getAreaRect(area) {\n            // 区域坐标（海辛提供，左上-右下；这里的 x/y 作为 sprite 底部锚点坐标来用）\n            // 休息区域范围（511,262）（841,621）\n            // 工作区域范围（190,526）（380,683）\n            // error 区域范围（932,275）（1109,327）\n            const rects = {\n                breakroom: { x1: 511, y1: 262, x2: 841, y2: 621 },\n                writing:   { x1: 190, y1: 526, x2: 380, y2: 683 },\n                error:     { x1: 932, y1: 275, x2: 1109, y2: 327 }\n            };\n            return rects[area] || rects.breakroom;\n        }\n\n        function randomInt(min, max) {\n            return Math.floor(Math.random() * (max - min + 1)) + min;\n        }\n\n        function randomPointInRect(rect) {\n            return { x: randomInt(rect.x1, rect.x2), y: randomInt(rect.y1, rect.y2) };\n        }\n\n        function getAreaPoint(area, idx) {\n            // 非 demo 访客：仍用固定点位，避免每次轮询都抖动。\n            const map = {\n                breakroom: [\n                    { x: 511, y: 262 },\n                    { x: 841, y: 621 },\n                    { x: 690, y: 470 }\n                ],\n                writing: [\n                    { x: 190, y: 526 },\n                    { x: 380, y: 683 },\n                    { x: 300, y: 610 }\n                ],\n                error: [\n                    { x: 932, y: 275 },\n                    { x: 1109, y: 327 },\n                    { x: 1020, y: 305 }\n                ]\n            };\n            const arr = map[area] || map.breakroom;\n            return arr[idx % arr.length];\n        }\n\n        function renderGuestAgentsInScene() {\n            if (!game) return;\n            const visitors = getMergedVisitors();\n            const seenIds = new Set();\n            let idxBreak = 0, idxWrite = 0, idxError = 0;\n\n            visitors.forEach(agent => {\n                const id = agent.agentId;\n                seenIds.add(id);\n\n                const isDemo = !!agent.isDemo || (DEMO_MODE && (id === 'demo_nika' || id === 'demo_mercury' || agent.name === '尼卡' || agent.name === '水星'));\n                const area = agent.area || (agent.state === 'error' ? 'error' : (agent.state === 'idle' ? 'breakroom' : 'writing'));\n\n                const idx = area === 'breakroom' ? idxBreak++ : area === 'error' ? idxError++ : idxWrite++;\n                const p = isDemo\n                    ? randomPointInRect(getAreaRect(area))\n                    : getAreaPoint(area, idx);\n\n                if (!guestSprites[id]) {\n                    // 优先用图标：demo visitor 有专门映射\n                    let sprite;\n                    const isDemoNika = DEMO_MODE && (agent.agentId === 'demo_nika' || agent.name === '尼卡');\n                    const isDemoMercury = DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星');\n                    \n                    if (isDemoNika || isDemoMercury) {\n                        // 统一使用动态像素角色，避免依赖已删除的 demo 静态图\n                        const animKey = 'guest_anim_1';\n                        const f = 0;\n                        sprite = game.add.sprite(p.x, p.y, animKey, f).setOrigin(0.5, 1).setScale(1.1);\n                        if (sprite.anims && sprite.anims.play) sprite.anims.play(animKey, true);\n                    } else {\n                        // 非 demo 访客：优先用动画精灵（guest_anim_x），其次静态图，兜底星星\n                        // 先确定角色索引（1-6）\n                        let animIdx = agent.avatar\n                            ? parseInt((agent.avatar.match(/_(\\d+)$/) || [])[1] || '0', 10)\n                            : 0;\n                        if (!animIdx || animIdx < 1 || animIdx > 6) {\n                            const aid = String(agent.agentId || '');\n                            let hash = 0;\n                            for (let i = 0; i < aid.length; i++) hash = (hash * 31 + aid.charCodeAt(i)) >>> 0;\n                            animIdx = (hash % 6) + 1;\n                        }\n                        const animKey = `guest_anim_${animIdx}`;\n                        const animIdleKey = `guest_anim_${animIdx}_idle`;\n\n                        if (game.textures.exists(animKey) && game.anims.exists(animIdleKey)) {\n                            sprite = game.add.sprite(p.x, p.y, animKey).setOrigin(0.5, 1).setScale(4.0);\n                            sprite.anims.play(animIdleKey, true);\n                        } else {\n                            const staticAvatarKey = agent.avatar && game.textures.exists(agent.avatar)\n                                ? agent.avatar\n                                : (() => {\n                                    const aid = String(agent.agentId || '');\n                                    let hash = 0;\n                                    for (let i = 0; i < aid.length; i++) hash = (hash * 31 + aid.charCodeAt(i)) >>> 0;\n                                    return GUEST_AVATARS[hash % GUEST_AVATARS.length];\n                                })();\n\n                            if (staticAvatarKey && game.textures.exists(staticAvatarKey)) {\n                                sprite = game.add.image(p.x, p.y, staticAvatarKey).setOrigin(0.5, 1).setScale(1.15);\n                            } else {\n                                sprite = game.add.text(p.x, p.y, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '30px' }).setOrigin(0.5, 1);\n                            }\n                        }\n                    }\n                    sprite.setDepth(2600);\n                    if (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) {\n                        sprite.y = sprite.y + 10;\n                    }\n\n                    // demo 水星下移 10px（仅 demo_mercury）\n                    const yOffset = (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) ? 10 : 0;\n\n                    const nameTextY = isDemo ? ((p.y + yOffset) - 80) : ((p.y + yOffset) - 120);\n                    const nameText = game.add.text(p.x, nameTextY, agent.name || '访客', {\n                        fontFamily: 'ArkPixel, monospace',\n                        fontSize: isDemo ? '16px' : '15px',\n                        fill: '#ffffff',\n                        stroke: '#000',\n                        strokeThickness: 3\n                    }).setOrigin(0.5);\n                    nameText.setDepth(2601);\n\n                    guestSprites[id] = { sprite, nameText };\n                } else {\n                    const g = guestSprites[id];\n                    const yOffset = (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) ? 10 : 0;\n\n                    // demo：平滑移动（避免闪现）；非 demo：保持稳定位置（避免轮询抖动）\n                    if (isDemo) {\n                        // kill previous tweens for this id\n                        if (guestTweens[id] && guestTweens[id].move) {\n                            guestTweens[id].move.stop();\n                        }\n                        if (guestTweens[id] && guestTweens[id].name) {\n                            guestTweens[id].name.stop();\n                        }\n\n                        const duration = 2000 + Math.floor(Math.random() * 1000); // 2~3s 走路感\n                        const ease = 'Sine.easeInOut';\n\n                        const moveTween = game.tweens.add({\n                            targets: g.sprite,\n                            x: p.x,\n                            y: p.y + yOffset,\n                            duration,\n                            ease\n                        });\n                        const nameTween = game.tweens.add({\n                            targets: g.nameText,\n                            x: p.x,\n                            y: (p.y + yOffset) - 80,\n                            duration,\n                            ease\n                        });\n                        guestTweens[id] = { move: moveTween, name: nameTween };\n                    } else {\n                        g.sprite.x = p.x;\n                        g.sprite.y = p.y + yOffset;\n                        g.nameText.x = p.x;\n                        g.nameText.y = (p.y + yOffset) - 120;\n                    }\n\n                    g.nameText.setText(agent.name || '访客');\n                }\n            });\n\n            // 删除消失的 agent + 清理其气泡/tween\n            Object.keys(guestSprites).forEach(id => {\n                if (!seenIds.has(id)) {\n                    guestSprites[id].sprite.destroy();\n                    guestSprites[id].nameText.destroy();\n                    delete guestSprites[id];\n                    if (guestBubbles[id]) {\n                        guestBubbles[id].destroy();\n                        delete guestBubbles[id];\n                    }\n                    if (guestTweens[id]) {\n                        try { guestTweens[id].move && guestTweens[id].move.stop(); } catch(e) {}\n                        try { guestTweens[id].name && guestTweens[id].name.stop(); } catch(e) {}\n                        delete guestTweens[id];\n                    }\n                }\n            });\n        }\n\n        function maybeShowGuestBubble(time) {\n            if (time - lastGuestBubbleAt < 5200) return;\n            lastGuestBubbleAt = time;\n            const ids = Object.keys(guestSprites);\n            if (ids.length === 0) return;\n            const id = ids[Math.floor(Math.random() * ids.length)];\n            const g = guestSprites[id];\n\n            // demo 气泡：优先展示与状态对应的内容，便于验证“状态→区域→气泡”链路\n            const demoVisitor = (DEMO_MODE && window.__demoVisitors)\n                ? (window.__demoVisitors.find(v => v.agentId === id) || window.__demoVisitors.find(v => v.name === (g.nameText && g.nameText.text)))\n                : null;\n\n            const statusThoughtsMap = {\n                idle: ['我在休息区待命', '先放松一下，等下一步任务', '我在休息充电中'],\n                writing: ['我在工作区处理任务', '正在整理文档与执行中', '工作区专注推进中'],\n                researching: ['我在调研模式，搜集信息', '正在查资料和验证线索', '研究中，稍后同步结论'],\n                executing: ['执行中，正在跑流程', '我在工作区推进任务', '正在把计划落地执行'],\n                syncing: ['同步中，马上更新状态', '正在同步进度到系统', '数据同步中请稍候'],\n                error: ['我在 bug 区排查问题', '检测到异常，正在修复', '报警中，先定位再处理']\n            };\n            const agentState = (guestAgents.find(a => a.agentId === id) || {}).state || 'idle';\n            const thoughts = statusThoughtsMap[agentState] || statusThoughtsMap.idle;\n            const text = (demoVisitor && demoVisitor.bubbleText) ? demoVisitor.bubbleText : thoughts[Math.floor(Math.random() * thoughts.length)];\n\n            if (guestBubbles[id]) {\n                guestBubbles[id].destroy();\n                delete guestBubbles[id];\n            }\n\n            const bx = g.sprite.x;\n            // 气泡位置：demo 维持原逻辑；真实访客放在“名字上方”，避免压角色也避免压名字\n            const isDemoGuest = (demoVisitor && demoVisitor.isDemo) || (id === 'demo_nika' || id === 'demo_mercury');\n            const nameH = (g.nameText && g.nameText.height) ? g.nameText.height : 16;\n            const by = isDemoGuest ? (g.sprite.y - 90) : ((g.nameText ? g.nameText.y : (g.sprite.y - 150)) - (nameH / 2) - 22);\n            const fontSize = IS_TOUCH_DEVICE ? 16 : 14;\n            const bg = game.add.rectangle(bx, by, text.length * 11 + 30, 34, 0xffffff, 0.95);\n            bg.setStrokeStyle(2, 0x000000);\n            const txt = game.add.text(bx, by, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000' }).setOrigin(0.5);\n            const bubble = game.add.container(0, 0, [bg, txt]);\n            bubble.setDepth(2700);\n            guestBubbles[id] = bubble;\n\n            // 让气泡跟随 sprite 锚点（用于 demo 平滑移动时也保持贴合）\n            bubble.__followAgentId = id;\n\n            setTimeout(() => {\n                if (guestBubbles[id]) {\n                    guestBubbles[id].destroy();\n                    delete guestBubbles[id];\n                }\n            }, 3200);\n        }\n\n        function maybeRandomizeDemoVisitors() {\n            if (!DEMO_MODE) return;\n            ensureDemoVisitors();\n\n            // 按海辛需求：每 8 秒切换一次状态\n            window.__demoNextAt = window.__demoNextAt || 0;\n            const now = Date.now();\n            if (now < window.__demoNextAt) return;\n            window.__demoNextAt = now + 8000;\n\n            const states = ['idle', 'writing', 'researching', 'executing', 'syncing', 'error'];\n            const bubbleTextMapByLang = {\n                zh: {\n                idle: '我去休息区躺一下',\n                writing: '我在工作中',\n                researching: '我在调研中',\n                executing: '我在执行任务',\n                syncing: '我在同步状态',\n                error: '出错了！我去报警区'\n                },\n                en: {\n                    idle: 'Taking a break in the lounge.',\n                    writing: 'I am working now.',\n                    researching: 'I am researching now.',\n                    executing: 'I am executing tasks.',\n                    syncing: 'I am syncing status.',\n                    error: 'Something broke! Heading to alert zone.'\n                },\n                ja: {\n                    idle: '休憩エリアでひと休み。',\n                    writing: '作業中です。',\n                    researching: '調査中です。',\n                    executing: 'タスクを実行中です。',\n                    syncing: '状態を同期中です。',\n                    error: 'エラー発生！アラートエリアへ。'\n                }\n            };\n            const bubbleTextMap = bubbleTextMapByLang[uiLang] || bubbleTextMapByLang.zh;\n\n            // 确保两位 demo 角色不会总是同一个状态（增加可观测性）\n            const pickJs = (exclude) => {\n                let s = states[Math.floor(Math.random() * states.length)];\n                let tries = 0;\n                while (exclude && s === exclude && tries < 5) {\n                    s = states[Math.floor(Math.random() * states.length)];\n                    tries++;\n                }\n                return s;\n            };\n\n            const current = window.__demoVisitors || [];\n            const cur0 = current[0] ? (current[0].state || 'idle') : 'idle';\n\n            const next0 = pickJs(cur0);\n            const next1 = pickJs(next0); // 尽量不同\n            const nextStates = [next0, next1];\n\n            const prevVisitors = current.map((v) => ({ ...v }));\n            window.__demoVisitors = current.map((v, i) => {\n                const nextState = nextStates[i] || pickJs(v.state);\n                return {\n                    ...v,\n                    state: nextState,\n                    bubbleText: bubbleTextMap[nextState] || String(nextState),\n                    updated_at: new Date().toISOString()\n                };\n            });\n\n            // 状态切换时：每一位 demo 都立即冒泡（强制），用于清晰验证链路\n            try {\n                if (typeof game !== 'undefined' && game) {\n                    // 找出状态实际变了的 demo visitor，给他们强制冒泡\n                    const prevById = {};\n                    prevVisitors.forEach(v => { prevById[v.agentId] = v; });\n                    const newVisitors = window.__demoVisitors || [];\n                    newVisitors.forEach(agent => {\n                        const prev = prevById[agent.agentId];\n                        const changed = !prev || prev.state !== agent.state;\n                        if (changed) {\n                            // 直接冒泡\n                            if (guestSprites[agent.agentId]) {\n                                const g = guestSprites[agent.agentId];\n                                const text = agent.bubbleText || '';\n                                if (guestBubbles[agent.agentId]) {\n                                    guestBubbles[agent.agentId].destroy();\n                                    delete guestBubbles[agent.agentId];\n                                }\n                                const bx = g.sprite.x;\n                                const by = g.sprite.y - 90;\n                                const fontSize = IS_TOUCH_DEVICE ? 16 : 14;\n                                const bg = game.add.rectangle(bx, by, text.length * 11 + 30, 34, 0xffffff, 0.95);\n                                bg.setStrokeStyle(2, 0x000000);\n                                const txt = game.add.text(bx, by, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000' }).setOrigin(0.5);\n                                const bubble = game.add.container(0, 0, [bg, txt]);\n                                bubble.setDepth(2700);\n                                bubble.__followAgentId = agent.agentId;\n                                guestBubbles[agent.agentId] = bubble;\n                                setTimeout(() => {\n                                    if (guestBubbles[agent.agentId]) {\n                                        guestBubbles[agent.agentId].destroy();\n                                        delete guestBubbles[agent.agentId];\n                                    }\n                                }, 3200);\n                            }\n                        }\n                    });\n                }\n            } catch (e) { console.error('强制冒泡失败:', e); }\n        }\n\n        function fetchGuestAgents() {\n            // demo 随机状态先更新（不依赖后端）\n            maybeRandomizeDemoVisitors();\n\n            return fetch('/agents?t=' + Date.now(), { cache: 'no-store' })\n                .then(response => response.json())\n                .then(data => {\n                    // 无论后端返回是否为数组，demo=1 都应保证本地 demo 访客可见\n                    guestAgents = Array.isArray(data) ? data : [];\n\n                    // 新访客检测：触发 Star 欢迎气泡（只欢迎真实访客，不欢迎 demo）\n                    try {\n                        const merged = getMergedVisitors();\n                        const currentIds = new Set((merged || []).filter(a => !a.isMain && !a.isDemo).map(a => a.agentId));\n\n                        if (!guestWelcomeInitialized) {\n                            // 首次初始化不欢迎，避免刷新页面就刷屏\n                            lastSeenGuestIds = currentIds;\n                            guestWelcomeInitialized = true;\n                        } else {\n                            const newIds = [];\n                            currentIds.forEach(id => { if (!lastSeenGuestIds.has(id)) newIds.push(id); });\n\n                            if (newIds.length > 0) {\n                                // 只欢迎第一个新来的（避免同一时刻多人加入刷屏）\n                                const newAgent = (merged || []).find(a => a.agentId === newIds[0]);\n                                if (newAgent && newAgent.name) {\n                                    // 临时将 currentState 视为 writing 以允许 showBubble 展示\n                                    const oldState = currentState;\n                                    currentState = 'writing';\n                                    // 临时更换 bubble 文案\n                                    const lang = uiLang;\n                                    const welcomeTexts = {\n                                        zh: [`欢迎 ${newAgent.name} 来到办公室～`,`Hi ${newAgent.name}，一起开工吧`,`${newAgent.name} 已加入，欢迎！`],\n                                        en: [`Welcome ${newAgent.name} to the office!`,`Hi ${newAgent.name}, let’s build something.`,`${newAgent.name} just joined — welcome!`],\n                                        ja: [`${newAgent.name} さん、オフィスへようこそ！`,`Hi ${newAgent.name}、一緒に進めよう。`,`${newAgent.name} さんが参加しました、歓迎！`]\n                                    };\n                                    const langPack = BUBBLE_TEXTS[lang] || BUBBLE_TEXTS.zh;\n                                    const oldTexts = Array.isArray(langPack.writing) ? [...langPack.writing] : [];\n                                    langPack.writing = welcomeTexts[lang] || welcomeTexts.zh;\n                                    showBubble();\n                                    // 还原\n                                    langPack.writing = oldTexts;\n                                    currentState = oldState;\n                                }\n                            }\n\n                            lastSeenGuestIds = currentIds;\n                        }\n                    } catch (e) { /* ignore */ }\n\n                    renderGuestAgentList();\n                    renderGuestAgentsInScene();\n                })\n                .catch(error => {\n                    console.error('拉取访客列表失败:', error);\n                    // 即使拉取失败，demo 也要能渲染\n                    if (DEMO_MODE) {\n                        renderGuestAgentList();\n                        renderGuestAgentsInScene();\n                    }\n                });\n        }\n\n        // 初始化：先检测 WebP 支持，再启动游戏\n        async function initGame() {\n            // 检测 WebP 支持\n            try {\n                supportsWebP = await checkWebPSupport();\n            } catch (e) {\n                try {\n                    supportsWebP = await checkWebPSupportFallback();\n                } catch (e2) {\n                    supportsWebP = false;\n                }\n            }\n            \n            console.log('WebP 支持:', supportsWebP);\n            initOfficePlaqueEditor();\n            applyLanguage();\n            updateSpeedModeUI();\n\n            // 动态探测 flowers 精灵表帧规格（避免写死 65x65 导致显示比例异常）\n            try {\n                const res = await fetch('/assets/list?t=' + Date.now(), { cache: 'no-store' });\n                const data = await res.json();\n                if (data && data.ok && Array.isArray(data.items)) {\n                    const flowerItem = data.items.find(it => (it.path || '').toLowerCase().includes('flowers-bloom-v2'));\n                    if (flowerItem && Number(flowerItem.width) > 0 && Number(flowerItem.height) > 0) {\n                        const w = Number(flowerItem.width);\n                        const h = Number(flowerItem.height);\n                        // 固定规则：花朵单帧 128x128，4x4\n                        FLOWERS_FRAME_W = 128;\n                        FLOWERS_FRAME_H = 128;\n                        FLOWERS_FRAME_COLS = 4;\n                        FLOWERS_FRAME_ROWS = 4;\n                    }\n                }\n            } catch (e) {\n                console.warn('flowers 规格探测失败，使用默认 65x65', e);\n            }\n            \n            // 启动 Phaser 游戏\n            new Phaser.Game(config);\n            setTimeout(async () => {\n                try {\n                    const authRes = await fetch('/assets/auth/status', { cache: 'no-store' });\n                    const authData = await authRes.json();\n                    if (authData && authData.ok && authData.authed) {\n                        await applySavedPositionOverrides();\n                    }\n                } catch (e) {}\n            }, 600);\n        }\n\n        function preload() {\n            // 获取加载界面元素\n            loadingOverlay = document.getElementById('loading-overlay');\n            loadingProgressBar = document.getElementById('loading-progress-bar');\n            loadingText = document.getElementById('loading-text');\n            loadingProgressContainer = document.getElementById('loading-progress-container');\n            \n            // 设置资源总数（全部首屏加载：装饰也第一时间出现）\n            totalAssets = 15;\n            loadedAssets = 0;\n            \n            // 加载进度监听\n            this.load.on('filecomplete', () => {\n                updateLoadingProgress();\n            });\n            \n            this.load.on('complete', () => {\n                hideLoadingOverlay();\n            });\n\n            // cache-busting to avoid stale background on client/CDN\n            // use smaller/new map version provided by user\n            this.load.image('office_bg', '/static/office_bg_small.webp?v={{VERSION_TIMESTAMP}}');\n            this.load.spritesheet('star_idle', '/static/star-idle-v5.png?v={{VERSION_TIMESTAMP}}', { frameWidth: 256, frameHeight: 256 });\n\n            // Furniture\n            this.load.image('sofa_idle', '/static/sofa-idle-v3.png?v={{VERSION_TIMESTAMP}}');\n            this.load.image('sofa_shadow', '/static/sofa-shadow-v1.png?v={{VERSION_TIMESTAMP}}');\n\n            // Decor\n            this.load.spritesheet('plants', '/static/plants-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });\n            this.load.spritesheet('posters', '/static/posters-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });\n            this.load.spritesheet('coffee_machine', '/static/coffee-machine-v3-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 230, frameHeight: 230 });\n            this.load.image('coffee_machine_shadow', '/static/coffee-machine-shadow-v1.png?v={{VERSION_TIMESTAMP}}');\n            this.load.spritesheet('serverroom', '/static/serverroom-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 180, frameHeight: 251 });\n\n            // Error / bug animation: 180x180, 96 frames (repacked grid)\n            this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 220, frameHeight: 220 });\n\n            // 运行时 Gemini 配置（用于搬家/中介生图）\n            this.geminiConfig = { hasKey: false, model: 'gemini-3.1-flash-image-preview' };\n\n            // Cat spritesheet: 160x160, 4x4=16 cats\n            this.load.spritesheet('cats', '/static/cats-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });\n\n            // Desk\n            // Star working animation: repacked to grid to avoid WebGL max texture size limits\n            // NOTE: prefer WebP for size, PNG fallback\n            // 动态替换后按最新素材识别：当前 writing 素材为 300x300 单帧\n            this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 300, frameHeight: 300 });\n\n            // Sync state animation (256x256, 多帧): 非同步显示首帧，同步从第2帧循环\n            this.load.spritesheet('sync_anim', '/static/sync-animation-v3-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 256, frameHeight: 256 });\n\n            // Memo background image\n            // memo 底图固定走 png，避免某些端 webp 透明通道异常导致“底图丢失”\n            this.load.image('memo_bg', '/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}');\n            // Desk v2 (webp only)\n            this.load.image('desk_v2', '/static/desk-v3.webp?v={{VERSION_TIMESTAMP}}');\n            // Flower spritesheet (65x65, 16 frames)\n            this.load.spritesheet('flowers', '/static/flowers-bloom-v2.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: FLOWERS_FRAME_W, frameHeight: FLOWERS_FRAME_H });\n\n            // Guest/Demo agent sprites\n            this.load.spritesheet('guest_anim_1', '/static/guest_anim_1.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_2', '/static/guest_anim_2.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_3', '/static/guest_anim_3.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_4', '/static/guest_anim_4.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_5', '/static/guest_anim_5.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_6', '/static/guest_anim_6.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n        }\n\n        function create() {\n            game = this;\n            officeBgSprite = this.add.image(640, 360, 'office_bg');\n            // Electron standalone: force room background to fill the full 16:9 stage width.\n            if (officeBgSprite && officeBgSprite.setDisplaySize) {\n                officeBgSprite.setDisplaySize(1280, 720);\n            }\n\n            // Place furniture: Sofa\n            // NOTE: coordinates are interpreted as the TOP-LEFT corner of the sprite\n            const sofaShadow = this.add.image(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y, 'sofa_shadow').setOrigin(0.5);\n            sofaShadow.setDepth(9);\n            sofa = this.add.sprite(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y, 'sofa_idle').setOrigin(0.5);\n            sofa.setDepth(10);\n            areas = {\n                door: { x: 640, y: 550 }, // 墙的门（偏下 1/3 位置）\n                writing: { x: 320, y: 360 }, // 左 1/3 中间（办公桌）\n                researching: { x: 320, y: 360 }, // 左 1/3 中间（研究也在办公区\n                error: { x: 1066, y: 180 }, // 右 1/3 上 1/2（服务器区\n                breakroom: { x: IDLE_SOFA_ANCHOR.x, y: IDLE_SOFA_ANCHOR.y } // 与 sofa-idle-v3 同中心锚点\n            };\n\n            // 创建 Star 角色待命动画（每次先移除旧定义，确保不复用历史动画）\n            const starIdleFrameMax = Math.max(0, (this.textures.get('star_idle')?.frameTotal || 1) - 1);\n            if (this.anims.exists('star_idle')) {\n                this.anims.remove('star_idle');\n            }\n            this.anims.create({\n                key: 'star_idle',\n                frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: starIdleFrameMax }),\n                frameRate: 12,\n                repeat: -1\n            });\n\n\n            // 创建 6 个访客角色的循环 idle 动画（8帧循环）\n            for (let i = 1; i <= 6; i++) {\n                this.anims.create({\n                    key: `guest_anim_${i}_idle`,\n                    frames: this.anims.generateFrameNumbers(`guest_anim_${i}`, { start: 0, end: 7 }),\n                    frameRate: 8,\n                    repeat: -1\n                });\n            }\n\n            star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle');\n            star.setOrigin(0.5);\n            star.setScale(IDLE_STAR_SCALE);\n            star.setAlpha(0.95);\n            star.setDepth(20); // Put Star on top of everything\n            // Default: idle shows Star idle animation\n            star.setVisible(true);\n            star.anims.play('star_idle', true);\n\n            // Sofa stays static when idle (no longer the main idle animation)\n            sofa.anims.stop();\n            sofa.setTexture('sofa_idle');\n\n            // Random plant at (565,178) (frame 0-15, 160x160 each)\n            const plantFrameCount = 16;\n            const randomPlantFrame = Math.floor(Math.random() * plantFrameCount);\n            const plant = game.add.sprite(565, 178, 'plants', randomPlantFrame).setOrigin(0.5);\n            plant.setDepth(5);\n            plant.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.plantSprite = plant;\n            window.plantFrameCount = plantFrameCount;\n            \n            plant.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.plantFrameCount);\n                window.plantSprite.setFrame(next);\n            });\n\n            // Random plant at (230,185) (frame 0-15, 160x160 each)\n            const plant2Frame = Math.floor(Math.random() * plantFrameCount);\n            const plant2 = game.add.sprite(230, 185, 'plants', plant2Frame).setOrigin(0.5);\n            plant2.setDepth(5);\n            plant2.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.plantSprite2 = plant2;\n            \n            plant2.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.plantFrameCount);\n                window.plantSprite2.setFrame(next);\n            });\n\n            // Random plant at (977,496) (frame 0-15, 160x160 each)\n            const plant3Frame = Math.floor(Math.random() * plantFrameCount);\n            const plant3 = game.add.sprite(977, 496, 'plants', plant3Frame).setOrigin(0.5);\n            plant3.setDepth(5);\n            plant3.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.plantSprite3 = plant3;\n            \n            plant3.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.plantFrameCount);\n                window.plantSprite3.setFrame(next);\n            });\n\n            // Random poster at (252,66) (random frame from spritesheet)\n            const postersFrameCount = (this.textures.get('posters')?.frameTotal || 1) - 1;\n            const randomPosterFrame = Math.floor(Math.random() * Math.max(1, postersFrameCount));\n            const poster = game.add.sprite(252, 66, 'posters', randomPosterFrame).setOrigin(0.5);\n            poster.setDepth(4);\n            poster.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.posterSprite = poster;\n            window.posterFrameCount = postersFrameCount;\n            \n            poster.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.posterFrameCount);\n                window.posterSprite.setFrame(next);\n            });\n\n            // Random cat at (94,557)\n            const catsFrameCount = (this.textures.get('cats')?.frameTotal || 1) - 1;\n            const randomCatFrame = Math.floor(Math.random() * Math.max(1, catsFrameCount));\n            const cat = game.add.sprite(94, 557, 'cats', randomCatFrame).setOrigin(0.5);\n            cat.setDepth(2000); // top layer\n            cat.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.catSprite = cat;\n            window.catsFrameCount = catsFrameCount;\n            cat.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.catsFrameCount);\n                window.catSprite.setFrame(next);\n            });\n\n            // Coffee machine at (659,397) - animated sprite + shadow\n            const coffeeMachineShadow = this.add.image(659, 397, 'coffee_machine_shadow').setOrigin(0.5);\n            coffeeMachineShadow.setDepth(98);\n            const coffeeFrameMax = Math.max(0, (this.textures.get('coffee_machine')?.frameTotal || 1) - 2);\n            if (this.anims.exists('coffee_machine')) {\n                this.anims.remove('coffee_machine');\n            }\n            this.anims.create({\n                key: 'coffee_machine',\n                frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: coffeeFrameMax }),\n                frameRate: 12.5,\n                repeat: -1\n            });\n            const coffeeMachine = this.add.sprite(659, 397, 'coffee_machine').setOrigin(0.5);\n            coffeeMachine.setDepth(99);\n            coffeeMachine.anims.play('coffee_machine', true);\n\n            // Server room animation\n            const serverFrameMax = Math.max(0, (this.textures.get('serverroom')?.frameTotal || 1) - 2);\n            this.anims.create({\n                key: 'serverroom_on',\n                frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: serverFrameMax }),\n                frameRate: 6,\n                repeat: -1\n            });\n            serverroom = this.add.sprite(1021, 142, 'serverroom', 0).setOrigin(0.5);\n            serverroom.setDepth(2);\n            // 默认 idle: 静止第0帧\n            serverroom.anims.stop();\n            serverroom.setFrame(0);\n\n            // Desk at (218,417) (v2)\n            const desk = this.add.image(218, 417, 'desk_v2').setOrigin(0.5);\n            desk.setDepth(1001);  // desk above starWorking\n\n            // Random flower pot at (310,390), default scale 0.8 (top layer)\n            const flowerFrameCount = Math.max(1, FLOWERS_FRAME_COLS * FLOWERS_FRAME_ROWS); // 动态帧数\n            const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount);\n            const flower = this.add.sprite(310, 390, 'flowers', randomFlowerFrame).setOrigin(0.5);\n            flower.setScale(0.8);\n            flower.setDepth(1100); // highest among desk/starWorking\n            flower.setInteractive({ useHandCursor: true });\n            window.flowerSprite = flower;\n            window.flowerFrameCount = flowerFrameCount;\n            flower.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.flowerFrameCount);\n                window.flowerSprite.setFrame(next);\n            });\n\n            // Star working at desk (217,333)\n            this.anims.create({\n                key: 'star_working',\n                // 38 帧（0~37），避免沿用旧 192 帧导致疯狂闪烁\n                frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 37 }),\n                frameRate: 12,\n                repeat: -1\n            });\n\n            // Error / bug animation (96 frames)\n            this.anims.create({\n                key: 'error_bug',\n                frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 71 }),\n                frameRate: 12,\n                repeat: -1\n            });\n\n            // Error bug character (moves between two points when state=error)\n            const errorBug = this.add.sprite(1007, 221, 'error_bug', 0).setOrigin(0.5);\n            errorBug.setDepth(50); // above serverroom, below desk/bubbles\n            errorBug.setVisible(false);\n            errorBug.setScale(0.9); // shrink 10%\n            errorBug.anims.play('error_bug', true);\n            window.errorBug = errorBug;\n            window.errorBugDir = 1; // 1 -> to right, -1 -> to left\n            const starWorking = this.add.sprite(217, 343, 'star_working', 0).setOrigin(0.5);\n            starWorking.setVisible(false);\n            starWorking.setScale(0.9);\n            starWorking.setDepth(900);  // starWorking under desk so desk partially covers it\n            // Store reference to starWorking for state logic\n            window.starWorking = starWorking;\n\n            // Sync animation sprite at (1157,592)\n            const syncFrameTotal = Number(this.textures.get('sync_anim')?.frameTotal || 0);\n            const syncFrameStart = 1;\n            const syncFrameEnd = Math.max(0, syncFrameTotal - 2);\n            // 仅在确实存在可播放帧（>=1）时才创建同步动画，避免单帧素材触发播放异常\n            syncAnimPlayable = syncFrameTotal >= 3 && syncFrameEnd >= syncFrameStart;\n            if (this.anims.exists('sync_anim')) {\n                this.anims.remove('sync_anim');\n            }\n            if (syncAnimPlayable) {\n            this.anims.create({\n                key: 'sync_anim',\n                    frames: this.anims.generateFrameNumbers('sync_anim', { start: syncFrameStart, end: syncFrameEnd }),\n                frameRate: 12,\n                repeat: -1\n            });\n            }\n            syncAnimSprite = this.add.sprite(1157, 592, 'sync_anim', 0).setOrigin(0.5);\n            syncAnimSprite.setDepth(40);\n            // default show first frame only\n            syncAnimSprite.anims.stop();\n            syncAnimSprite.setFrame(0);\n\n            // Debug: expose star sprite too (for path calibration / visuals)\n            window.starSprite = star;\n\n            statusText = document.getElementById('status-text');\n            if (DESKTOP_MODE) {\n                statusText = document.getElementById('status-fab') || statusText;\n            }\n            placeOverlayAndStatusAtCanvasBottomLeft();\n            window.addEventListener('resize', placeOverlayAndStatusAtCanvasBottomLeft);\n            window.addEventListener('scroll', placeOverlayAndStatusAtCanvasBottomLeft, { passive: true });\n            coordsOverlay = document.getElementById('coords-overlay');\n            coordsDisplay = document.getElementById('coords-display');\n            coordsToggle = document.getElementById('coords-toggle');\n\n            // guest agent 将由 /agents 动态拉取并渲染到右侧访客列表\n            coordsToggle.addEventListener('click', () => {\n                showCoords = !showCoords;\n                coordsOverlay.style.display = showCoords ? 'block' : 'none';\n                coordsToggle.textContent = showCoords ? t('hideCoords') : t('showCoords');\n                coordsToggle.style.background = showCoords ? '#e94560' : '#333';\n            });\n\n            // 允许手机端“拖动/滑动”来移动视野（本质：移动 Phaser Camera）\n            // iPhone 等触屏设备默认开启；桌面端默认关闭（可手动开）。\n            const panToggle = document.getElementById('pan-toggle');\n            const isTouchDevice = IS_TOUCH_DEVICE;\n            let panEnabled = false;\n            let isPanning = false;\n            let panStart = null; // {x,y,sx,sy}\n            const camera = game.cameras.main;\n\n            const MAP_W = config.width;\n            const MAP_H = config.height;\n            function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }\n            function maxScrollX() {\n                const viewportW = camera.width / Math.max(0.01, camera.zoom);\n                return Math.max(0, MAP_W - viewportW);\n            }\n            function maxScrollY() {\n                const viewportH = camera.height / Math.max(0.01, camera.zoom);\n                return Math.max(0, MAP_H - viewportH);\n            }\n            function clampCameraScroll() {\n                camera.scrollX = clamp(camera.scrollX, 0, maxScrollX());\n                camera.scrollY = clamp(camera.scrollY, 0, maxScrollY());\n            }\n\n            // 手机上：锁定“办公室画布高度 = 2/3 区域高度”，\n            // 让世界坐标在竖向恰好看满（不需要上下拖），只保留横向拖动浏览左右。\n            // 记录初始是否已手动平移（避免 resize 时把用户拖好的位置重置）\n            let hasManuallyPanned = false;\n            function applyMobileCameraFit() {\n                if (!isTouchDevice) return;\n                const h = Math.max(1, camera.height);\n                const w = Math.max(1, camera.width);\n\n                // 关键：先按高度 fit，再看是否需要按宽度微调，\n                // 保证既不会让画面歪，又能左右拖到最左最右边缘不被裁。\n                const fitHeightZoom = h / MAP_H;\n                const candidateZoom = fitHeightZoom;\n\n                // 按 candidateZoom 计算：viewport 在世界坐标里的宽高\n                const viewW = w / candidateZoom;\n                const maxX = Math.max(0, MAP_W - viewW);\n\n                camera.setZoom(candidateZoom);\n                camera.scrollX = Math.min(camera.scrollX, maxX);\n                camera.scrollY = 0;\n\n                // 仅在未手动平移过时才居中（避免把用户拖好的位置冲掉）\n                if (!hasManuallyPanned) {\n                    camera.centerOn(MAP_W / 2, MAP_H / 2);\n                }\n                camera.scrollX = clamp(camera.scrollX, 0, maxX);\n                camera.scrollY = 0;\n            }\n            applyMobileCameraFit();\n\n            // 手机端旋转屏幕/地址栏伸缩时，重算 zoom + 夹紧 camera\n            if (isTouchDevice && game.scale) {\n                game.scale.on('resize', () => {\n                    applyMobileCameraFit();\n                    placeOverlayAndStatusAtCanvasBottomLeft();\n                });\n            }\n\n            camera.setBounds(0, 0, MAP_W, MAP_H);\n            clampCameraScroll();\n            if (DESKTOP_MODE && !isTouchDevice) {\n                camera.centerOn(MAP_W / 2, MAP_H / 2);\n            }\n\n            function setPanEnabled(on) {\n                panEnabled = on;\n                if (panToggle) {\n                    panToggle.dataset.on = on ? '1' : '0';\n                    panToggle.textContent = on ? t('lockView') : t('moveView');\n                    panToggle.style.background = on ? '#e94560' : '#333';\n                }\n                game.input.setDefaultCursor(on ? 'grab' : 'default');\n                if (isTouchDevice && statusText) {\n                    const info = on ? '视野拖动已开启（可左右拖动画布）' : '视野拖动已关闭（点击左上角“移动视野”可开启）';\n                    statusText.textContent = `[${getStateLabelByState(currentState)}] ${info}`;\n                }\n            }\n\n            if (panToggle) {\n                panToggle.addEventListener('click', () => setPanEnabled(!panEnabled));\n            }\n\n            // 手机端默认关闭拖动画面：由左上角“移动视野”开关显式开启\n            if (isTouchDevice) {\n                setPanEnabled(false);\n            }\n\n            // iOS/Safari 手势策略：\n            // - 保留垂直滚动（让页面能下滑看三个面板）\n            // - 水平方向拖动时才阻止默认行为，并转为 camera 横向平移\n            // 说明：iOS 对 pointer + touch-action 支持存在机型差异，所以这里加一套原生 touch 兜底。\n            const canvasEl = game.canvas;\n            let touchPan = null; // {x,y,sx,sy,lock:'x'|'y'|null}\n            if (canvasEl) {\n                // 手机端允许页面自然滚动，避免“不能滑动”\n                canvasEl.style.touchAction = 'auto';\n\n                canvasEl.addEventListener('touchstart', (e) => {\n                    if (!panEnabled || e.touches.length !== 1) return;\n                    const t = e.touches[0];\n                    touchPan = { x: t.clientX, y: t.clientY, sx: camera.scrollX, sy: camera.scrollY, lock: null };\n                }, { passive: true });\n\n                canvasEl.addEventListener('touchmove', (e) => {\n                    if (!panEnabled || !touchPan || e.touches.length !== 1) return;\n                    const t = e.touches[0];\n                    const dx = t.clientX - touchPan.x;\n                    const dy = t.clientY - touchPan.y;\n\n                    if (!touchPan.lock) {\n                        if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;\n                        touchPan.lock = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';\n                    }\n\n                    if (touchPan.lock === 'x') {\n                        // 横向拖动交给办公室视野；阻止浏览器默认滚动\n                        e.preventDefault();\n                        hasManuallyPanned = true;\n                        camera.scrollX = clamp(touchPan.sx - dx, 0, maxScrollX());\n                    }\n                    // lock==='y' 时不阻止默认，交给页面纵向滚动\n                }, { passive: false });\n\n                const clearTouchPan = () => { touchPan = null; };\n                canvasEl.addEventListener('touchend', clearTouchPan, { passive: true });\n                canvasEl.addEventListener('touchcancel', clearTouchPan, { passive: true });\n            }\n\n            game.input.on('pointerdown', (pointer) => {\n                if (!panEnabled) return;\n                isPanning = true;\n                panStart = { x: pointer.x, y: pointer.y, sx: camera.scrollX, sy: camera.scrollY };\n                game.input.setDefaultCursor('grabbing');\n            });\n\n            game.input.on('pointerup', () => {\n                if (!panEnabled) return;\n                isPanning = false;\n                panStart = null;\n                game.input.setDefaultCursor('grab');\n            });\n\n            game.input.on('pointermove', (pointer) => {\n                if (!panEnabled || !isPanning || !panStart) return;\n                const dx = pointer.x - panStart.x;\n                const dy = pointer.y - panStart.y;\n\n                // 手机端优先“横向拖动看办公室”，纵向手势留给页面滚动看下方面板。\n                if (isTouchDevice && Math.abs(dy) > Math.abs(dx)) {\n                    return;\n                }\n\n                // 手指向右拖，视野跟着向右看：camera scroll 向左减小（反向）\n                const newX = panStart.sx - dx;\n                hasManuallyPanned = true;\n                camera.scrollX = clamp(newX, 0, maxScrollX());\n\n                // 桌面端保留自由二维拖动\n                if (!isTouchDevice) {\n                    const newY = panStart.sy - dy;\n                    camera.scrollY = clamp(newY, 0, maxScrollY());\n                }\n            });\n\n            // Mouse move handler for coordinate display\n            game.input.on('pointermove', (pointer) => {\n                if (!showCoords) return;\n                // Clamp to map size (0..width-1 / 0..height-1)\n                const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x)));\n                const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y)));\n                coordsDisplay.textContent = `${x}, ${y}`;\n                // Position overlay next to mouse\n                coordsOverlay.style.left = (pointer.x + 18) + 'px';\n                coordsOverlay.style.top = (pointer.y + 18) + 'px';\n            });\n\n            // 加载昨日 memo\n            loadMemo();\n\n            fetchStatus();\n            fetchGuestAgents();\n        }\n\n        function update(time) {\n            if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; }\n            if (time - lastGuestAgentsFetch > GUEST_AGENTS_FETCH_INTERVAL) { fetchGuestAgents(); lastGuestAgentsFetch = time; }\n\n            // 兜底：非 idle 时确保机房动画在播，idle 时静止\n            const effectiveStateForServer = pendingDesiredState || currentState;\n            if (serverroom) {\n                if (effectiveStateForServer === 'idle') {\n                    if (serverroom.anims.isPlaying) {\n                        serverroom.anims.stop();\n                        serverroom.setFrame(0);\n                    }\n                } else {\n                    if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') {\n                        serverroom.anims.play('serverroom_on', true);\n                    }\n                }\n            }\n\n            // error 状态：显示 bug 动画，并在两点之间来回移动\n            if (window.errorBug) {\n                if (effectiveStateForServer === 'error') {\n                    window.errorBug.setVisible(true);\n                    if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') {\n                        window.errorBug.anims.play('error_bug', true);\n                    }\n                    // 固定在原地（按需求取消 error 移动路径）\n                    window.errorBug.x = 1007;\n                    window.errorBug.y = 221;\n                } else {\n                    window.errorBug.setVisible(false);\n                    window.errorBug.anims.stop();\n                }\n            }\n\n            // Sync animation fallback logic\n            if (syncAnimSprite) {\n                if (effectiveStateForServer === 'syncing') {\n                    if (syncAnimPlayable && syncAnimSprite.anims && syncAnimSprite.anims.play && syncAnimSprite.scene?.anims?.exists('sync_anim')) {\n                    if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n                        syncAnimSprite.anims.play('sync_anim', true);\n                    }\n                } else {\n                        syncAnimSprite.setFrame(0);\n                    }\n                } else {\n                    if (syncAnimSprite.anims && syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n                    syncAnimSprite.setFrame(0);\n                }\n            }\n\n            // 冒气泡\n            if (time - lastBubble > BUBBLE_INTERVAL) {\n                showBubble();\n                lastBubble = time;\n            }\n            // 猫的气泡（频率低）\n            if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) {\n                showCatBubble();\n                lastCatBubble = time;\n            }\n\n            // 打字机效果\n            if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) {\n                typewriterText += typewriterTarget[typewriterIndex];\n                statusText.textContent = typewriterText;\n                typewriterIndex++;\n                lastTypewriter = time;\n            }\n\n            // 移动 + 小踱步\n            moveStar(time);\n\n            // guest 随机想法泡泡\n            maybeShowGuestBubble(time);\n\n            // demo 平滑移动时：让气泡每帧跟随角色锚点（避免 tween 时气泡滞留在旧位置）\n            try {\n                Object.keys(guestBubbles).forEach(id => {\n                    const b = guestBubbles[id];\n                    const g = guestSprites[id];\n                    if (!b || !g) return;\n                    if (b.__followAgentId !== id) return;\n                    b.x = 0;\n                    b.y = 0;\n                    // children[0]=bg, children[1]=text\n                    const bx = g.sprite.x;\n                    const isDemoGuest = (id === 'demo_nika' || id === 'demo_mercury');\n                    const nameH = (g.nameText && g.nameText.height) ? g.nameText.height : 16;\n                    const by = isDemoGuest ? (g.sprite.y - 90) : ((g.nameText ? g.nameText.y : (g.sprite.y - 150)) - (nameH / 2) - 22);\n                    if (b.list && b.list[0]) { b.list[0].x = bx; b.list[0].y = by; }\n                    if (b.list && b.list[1]) { b.list[1].x = bx; b.list[1].y = by; }\n                });\n            } catch (e) {}\n\n            // guest 列表会定时刷新\n        }\n\n        function normalizeState(s) {\n            if (!s) return 'idle';\n            if (s === 'working') return 'writing';\n            if (s === 'run' || s === 'running') return 'executing';\n            if (s === 'sync') return 'syncing';\n            if (s === 'research') return 'researching';\n            return s;\n        }\n\n        function fetchStatus() {\n            return fetch('/status', { cache: 'no-store' })\n                .then(response => response.json())\n                .then(data => {\n                    try {\n                        if (data.officeName) {\n                            window.officeNameFromServer = data.officeName;\n                            refreshOfficePlaqueTitle();\n                        }\n                    const nextState = normalizeState(data.state);\n                    const stateInfo = STATES[nextState] || STATES.idle;\n                    // If we're mid-transition, don't restart the path every poll\n                    const changed = (pendingDesiredState === null) && (nextState !== currentState);\n                    const nextLine = '[' + getStateLabelByState(nextState) + '] ' + (data.detail || getStateDetailByState(nextState));\n                    if (changed) {\n                        typewriterTarget = nextLine;\n                        typewriterText = '';\n                        typewriterIndex = 0;\n\n                        // Set state immediately (no waypoints/path movement)\n                        pendingDesiredState = null;\n                        currentState = nextState;\n\n                            // Idle: show Star idle animation (main character)\n                        if (nextState === 'idle') {\n                                sofa.anims.stop();\n                                sofa.setTexture('sofa_idle');\n\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(false);\n                                window.starWorking.anims.stop();\n                            }\n\n                                star.setVisible(true);\n                                star.setScale(IDLE_STAR_SCALE);\n                                star.anims.play('star_idle', true);\n                                star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n                        } else if (nextState === 'error') {\n                            // Error: no working animation at desk\n                            sofa.anims.stop();\n                            sofa.setTexture('sofa_idle');\n                            star.setVisible(false);\n                            star.anims.stop();\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(false);\n                                window.starWorking.anims.stop();\n                            }\n                        } else if (nextState === 'syncing') {\n                            // Syncing: also no working animation at desk\n                            sofa.anims.stop();\n                            sofa.setTexture('sofa_idle');\n                            star.setVisible(false);\n                            star.anims.stop();\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(false);\n                                window.starWorking.anims.stop();\n                            }\n                        } else {\n                            // Non-idle non-error: starWorking animation at desk\n                            sofa.anims.stop();\n                            sofa.setTexture('sofa_idle');\n                            // Hide moving star, show desk star\n                            star.setVisible(false);\n                            star.anims.stop();\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(true);\n                                window.starWorking.anims.play('star_working', true);\n                            }\n                        }\n\n                        // Server room logic:\n                        if (serverroom) {\n                            if (nextState === 'idle') {\n                                serverroom.anims.stop();\n                                serverroom.setFrame(0);\n                            } else {\n                                serverroom.anims.play('serverroom_on', true);\n                            }\n                        }\n\n                        // Sync animation logic:\n                        // default: frame 0\n                        // state=syncing: play from frame 1\n                        if (syncAnimSprite) {\n                            if (nextState === 'syncing') {\n                                    if (syncAnimPlayable && syncAnimSprite.anims && syncAnimSprite.anims.play && syncAnimSprite.scene?.anims?.exists('sync_anim')) {\n                                if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n                                    syncAnimSprite.anims.play('sync_anim', true);\n                                }\n                            } else {\n                                        syncAnimSprite.setFrame(0);\n                                    }\n                                } else {\n                                    if (syncAnimSprite.anims && syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n                                syncAnimSprite.setFrame(0);\n                            }\n                        }\n                    } else {\n                        if (!typewriterTarget || typewriterTarget !== nextLine) {\n                            typewriterTarget = nextLine;\n                            typewriterText = '';\n                            typewriterIndex = 0;\n                        }\n                        }\n                    } catch (err) {\n                        console.error('fetchStatus apply error', err);\n                        typewriterTarget = '状态更新异常，正在恢复...';\n                        typewriterText = '';\n                        typewriterIndex = 0;\n                    }\n                })\n                .catch(error => {\n                    typewriterTarget = '连接失败，正在重试...';\n                    typewriterText = '';\n                    typewriterIndex = 0;\n                });\n        }\n\n        function moveStar(time) {\n            // Use pending state if available (for target area during transition)\n            const effectiveState = pendingDesiredState || currentState;\n            const stateInfo = STATES[effectiveState] || STATES.idle;\n            const baseTarget = areas[stateInfo.area] || areas.breakroom;\n\n            // idle 时锁定位置（不走任何移动路径）\n            if (effectiveState === 'idle') {\n                if (star && star.visible) {\n                    star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n                }\n                isMoving = false;\n                return;\n            }\n\n            const dx = targetX - star.x;\n            const dy = targetY - star.y;\n            const dist = Math.sqrt(dx * dx + dy * dy);\n            const speed = 1.4;\n            const wobble = Math.sin(time / 200) * 0.8;\n\n            if (dist > 3) {\n                // Move toward current target\n                star.x += (dx / dist) * speed;\n                star.y += (dy / dist) * speed;\n                star.setY(star.y + wobble);\n                isMoving = true;\n            } else {\n                // Arrived at a waypoint or final target\n                if (waypoints && waypoints.length > 0) {\n                    // Remove the first waypoint (we just arrived there)\n                    waypoints.shift();\n                    if (waypoints.length > 0) {\n                        // Next waypoint exists\n                        targetX = waypoints[0].x;\n                        targetY = waypoints[0].y;\n                        isMoving = true;\n                    } else {\n                        // Final target: apply pending state and switch visual\n                        if (pendingDesiredState !== null) {\n                            isMoving = false;\n                            currentState = pendingDesiredState;\n                            pendingDesiredState = null;\n\n                            if (currentState === 'idle') {\n                                if (window.starWorking) {\n                                    window.starWorking.setVisible(false);\n                                    window.starWorking.anims.stop();\n                                }\n                                star.setVisible(true);\n                                star.setScale(IDLE_STAR_SCALE);\n                                star.anims.play('star_idle', true);\n                                star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n                                sofa.anims.stop();\n                                sofa.setTexture('sofa_idle');\n                            } else {\n                                // Arrived at desk area: switch to star_working animation\n                                star.setVisible(false);\n                                star.anims.stop();\n                                if (window.starWorking) {\n                                    window.starWorking.setVisible(true);\n                                    window.starWorking.anims.play('star_working', true);\n                                }\n                            }\n                        }\n                    }\n                } else {\n                    if (pendingDesiredState !== null) {\n                        isMoving = false;\n                        currentState = pendingDesiredState;\n                        pendingDesiredState = null;\n\n                        if (currentState === 'idle') {\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(false);\n                                window.starWorking.anims.stop();\n                            }\n                            star.setVisible(true);\n                            star.setScale(IDLE_STAR_SCALE);\n                            star.anims.play('star_idle', true);\n                            star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n                            sofa.anims.stop();\n                            sofa.setTexture('sofa_idle');\n                        } else {\n                            // Arrived at desk area: switch to star_working animation\n                            star.setVisible(false);\n                            star.anims.stop();\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(true);\n                                window.starWorking.anims.play('star_working', true);\n                            }\n                            sofa.anims.stop();\n                            sofa.setTexture('sofa_idle');\n                        }\n                    }\n                }\n            }\n\n            // Small wander only after arrival (non-idle)\n            // Temporarily disabled to stay in work area; uncomment later if needed\n            /*\n            if (!isMoving && currentState !== 'idle' && pendingDesiredState === null && (time - lastWanderAt) > 3500) {\n                targetX = baseTarget.x + (Math.random() - 0.5) * 60;\n                targetY = baseTarget.y + (Math.random() - 0.5) * 40;\n                star.setVisible(true);\n                star.anims.play('star_idle', true);\n                isMoving = true;\n                lastWanderAt = time;\n            }\n            */\n        }\n\n        function getBubbleTextsByState(stateKey) {\n            const langPack = BUBBLE_TEXTS[uiLang] || BUBBLE_TEXTS.zh;\n            return langPack[stateKey] || langPack.idle || [];\n        }\n\n        function showBubble() {\n            if (bubble) { bubble.destroy(); bubble = null; }\n            const texts = getBubbleTextsByState(currentState);\n            if (currentState === 'idle') return; // idle 不显示气泡（可按需开启）\n\n            // Bubble anchor should follow current visible character:\n            // - syncing: syncAnimSprite\n            // - error state: errorBug\n            // - working at desk: starWorking\n            // - other: star\n            let anchorX = star.x;\n            let anchorY = star.y;\n            if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) {\n                anchorX = syncAnimSprite.x;\n                anchorY = syncAnimSprite.y;\n            } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) {\n                anchorX = window.errorBug.x;\n                anchorY = window.errorBug.y;\n            } else if (!star.visible && window.starWorking && window.starWorking.visible) {\n                anchorX = window.starWorking.x;\n                anchorY = window.starWorking.y;\n            }\n\n            const text = texts[Math.floor(Math.random() * texts.length)];\n            const bubbleOffsetY = (currentState === 'writing') ? 85 : 70;\n            const bubbleY = anchorY - bubbleOffsetY;\n\n            // 只做手机端稍微调大一点，避免发糊\n            const isTouch = IS_TOUCH_DEVICE;\n            const fontSize = isTouch ? 16 : 14;\n            const bg = game.add.rectangle(anchorX, bubbleY, text.length * 11 + 28, 34, 0xffffff, 0.95);\n            bg.setStrokeStyle(2, 0x000000);\n            const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000', align: 'center' }).setOrigin(0.5);\n            bubble = game.add.container(0, 0, [bg, txt]);\n            bubble.setDepth(1200); // always above desk/star\n            setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000);\n        }\n\n        function showCatBubble() {\n            if (!window.catSprite) return;\n            if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; }\n            const texts = getBubbleTextsByState('cat');\n            const text = texts[Math.floor(Math.random() * texts.length)];\n            const anchorX = window.catSprite.x;\n            const anchorY = window.catSprite.y - 60;\n            const bg = game.add.rectangle(anchorX, anchorY, text.length * 11 + 24, 28, 0xfffbeb, 0.95);\n            bg.setStrokeStyle(2, 0xd4a574);\n            const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '13px', fill: '#8b6914', align: 'center' }).setOrigin(0.5);\n            window.catBubble = game.add.container(0, 0, [bg, txt]);\n            window.catBubble.setDepth(2100); // top layer above cat\n            setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000);\n        }\n\n        // 假 Agent 气泡逻辑已移除，统一以真实 /agents 数据为准\n\n        // 启动页面\n        if (ASSET_WINDOW_MODE) {\n            applyLanguage();\n            updateSpeedModeUI();\n            toggleAssetDrawer(true);\n        } else {\n            initGame();\n        }\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/fonts/OFL.txt",
    "content": "Copyright (c) 2021, TakWolf (https://takwolf.com),\r\nwith Reserved Font Name \"Ark Pixel\".\r\n\r\nThis Font Software is licensed under the SIL Open Font License, Version 1.1.\r\nThis license is copied below, and is also available with a FAQ at:\r\nhttps://openfontlicense.org\r\n\r\n\r\n-----------------------------------------------------------\r\nSIL OPEN FONT LICENSE Version 1.1 - 26 February 2007\r\n-----------------------------------------------------------\r\n\r\nPREAMBLE\r\nThe goals of the Open Font License (OFL) are to stimulate worldwide\r\ndevelopment of collaborative font projects, to support the font creation\r\nefforts of academic and linguistic communities, and to provide a free and\r\nopen framework in which fonts may be shared and improved in partnership\r\nwith others.\r\n\r\nThe OFL allows the licensed fonts to be used, studied, modified and\r\nredistributed freely as long as they are not sold by themselves. The\r\nfonts, including any derivative works, can be bundled, embedded,\r\nredistributed and/or sold with any software provided that any reserved\r\nnames are not used by derivative works. The fonts and derivatives,\r\nhowever, cannot be released under any other type of license. The\r\nrequirement for fonts to remain under this license does not apply\r\nto any document created using the fonts or their derivatives.\r\n\r\nDEFINITIONS\r\n\"Font Software\" refers to the set of files released by the Copyright\r\nHolder(s) under this license and clearly marked as such. This may\r\ninclude source files, build scripts and documentation.\r\n\r\n\"Reserved Font Name\" refers to any names specified as such after the\r\ncopyright statement(s).\r\n\r\n\"Original Version\" refers to the collection of Font Software components as\r\ndistributed by the Copyright Holder(s).\r\n\r\n\"Modified Version\" refers to any derivative made by adding to, deleting,\r\nor substituting -- in part or in whole -- any of the components of the\r\nOriginal Version, by changing formats or by porting the Font Software to a\r\nnew environment.\r\n\r\n\"Author\" refers to any designer, engineer, programmer, technical\r\nwriter or other person who contributed to the Font Software.\r\n\r\nPERMISSION & CONDITIONS\r\nPermission is hereby granted, free of charge, to any person obtaining\r\na copy of the Font Software, to use, study, copy, merge, embed, modify,\r\nredistribute, and sell modified and unmodified copies of the Font\r\nSoftware, subject to the following conditions:\r\n\r\n1) Neither the Font Software nor any of its individual components,\r\nin Original or Modified Versions, may be sold by itself.\r\n\r\n2) Original or Modified Versions of the Font Software may be bundled,\r\nredistributed and/or sold with any software, provided that each copy\r\ncontains the above copyright notice and this license. These can be\r\nincluded either as stand-alone text files, human-readable headers or\r\nin the appropriate machine-readable metadata fields within text or\r\nbinary files as long as those fields can be easily viewed by the user.\r\n\r\n3) No Modified Version of the Font Software may use the Reserved Font\r\nName(s) unless explicit written permission is granted by the corresponding\r\nCopyright Holder. This restriction only applies to the primary font name as\r\npresented to the users.\r\n\r\n4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font\r\nSoftware shall not be used to promote, endorse or advertise any\r\nModified Version, except to acknowledge the contribution(s) of the\r\nCopyright Holder(s) and the Author(s) or with their explicit written\r\npermission.\r\n\r\n5) The Font Software, modified or unmodified, in part or in whole,\r\nmust be distributed entirely under this license, and must not be\r\ndistributed under any other license. The requirement for fonts to\r\nremain under this license does not apply to any document created\r\nusing the Font Software.\r\n\r\nTERMINATION\r\nThis license becomes null and void if any of the above conditions are\r\nnot met.\r\n\r\nDISCLAIMER\r\nTHE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\r\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\r\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\r\nOF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE\r\nCOPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\r\nINCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL\r\nDAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\r\nFROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM\r\nOTHER DEALINGS IN THE FONT SOFTWARE.\r\n"
  },
  {
    "path": "frontend/game.js",
    "content": "// Star Office UI - 游戏主逻辑\n// 依赖: layout.js（必须在这个之前加载）\n\n// 检测浏览器是否支持 WebP\nlet supportsWebP = false;\n\n// 方法 1: 使用 canvas 检测\nfunction checkWebPSupport() {\n  return new Promise((resolve) => {\n    const canvas = document.createElement('canvas');\n    if (canvas.getContext && canvas.getContext('2d')) {\n      resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0);\n    } else {\n      resolve(false);\n    }\n  });\n}\n\n// 方法 2: 使用 image 检测（备用）\nfunction checkWebPSupportFallback() {\n  return new Promise((resolve) => {\n    const img = new Image();\n    img.onload = () => resolve(true);\n    img.onerror = () => resolve(false);\n    img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA==';\n  });\n}\n\n// 获取文件扩展名（根据 WebP 支持情况 + 布局配置的 forcePng）\nfunction getExt(pngFile) {\n  // star-working-spritesheet.png 太宽了，WebP 不支持，始终用 PNG\n  if (pngFile === 'star-working-spritesheet.png') {\n    return '.png';\n  }\n  // 如果布局配置里强制用 PNG，就用 .png\n  if (LAYOUT.forcePng && LAYOUT.forcePng[pngFile.replace(/\\.(png|webp)$/, '')]) {\n    return '.png';\n  }\n  return supportsWebP ? '.webp' : '.png';\n}\n\nconst config = {\n  type: Phaser.AUTO,\n  width: LAYOUT.game.width,\n  height: LAYOUT.game.height,\n  parent: 'game-container',\n  pixelArt: true,\n  physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },\n  scene: { preload: preload, create: create, update: update }\n};\n\nlet totalAssets = 0;\nlet loadedAssets = 0;\nlet loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText;\n\n// Memo 相关函数\nasync function loadMemo() {\n  const memoDate = document.getElementById('memo-date');\n  const memoContent = document.getElementById('memo-content');\n\n  try {\n    const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' });\n    const data = await response.json();\n\n    if (data.success && data.memo) {\n      memoDate.textContent = data.date || '';\n      memoContent.innerHTML = data.memo.replace(/\\n/g, '<br>');\n    } else {\n      memoContent.innerHTML = '<div id=\"memo-placeholder\">暂无昨日日记</div>';\n    }\n  } catch (e) {\n    console.error('加载 memo 失败:', e);\n    memoContent.innerHTML = '<div id=\"memo-placeholder\">加载失败</div>';\n  }\n}\n\n// 更新加载进度\nfunction updateLoadingProgress() {\n  loadedAssets++;\n  const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100));\n  if (loadingProgressBar) {\n    loadingProgressBar.style.width = percent + '%';\n  }\n  if (loadingText) {\n    loadingText.textContent = `正在加载 Star 的像素办公室... ${percent}%`;\n  }\n}\n\n// 隐藏加载界面\nfunction hideLoadingOverlay() {\n  setTimeout(() => {\n    if (loadingOverlay) {\n      loadingOverlay.style.transition = 'opacity 0.5s ease';\n      loadingOverlay.style.opacity = '0';\n      setTimeout(() => {\n        loadingOverlay.style.display = 'none';\n      }, 500);\n    }\n  }, 300);\n}\n\nconst STATES = {\n  idle: { name: '待命', area: 'breakroom' },\n  writing: { name: '整理文档', area: 'writing' },\n  researching: { name: '搜索信息', area: 'researching' },\n  executing: { name: '执行任务', area: 'writing' },\n  syncing: { name: '同步备份', area: 'writing' },\n  error: { name: '出错了', area: 'error' }\n};\n\nconst BUBBLE_TEXTS = {\n  idle: [\n    '待命中：耳朵竖起来了',\n    '我在这儿，随时可以开工',\n    '先把桌面收拾干净再说',\n    '呼——给大脑放个风',\n    '今天也要优雅地高效',\n    '等待，是为了更准确的一击',\n    '咖啡还热，灵感也还在',\n    '我在后台给你加 Buff',\n    '状态：静心 / 充电',\n    '小猫说：慢一点也没关系'\n  ],\n  writing: [\n    '进入专注模式：勿扰',\n    '先把关键路径跑通',\n    '我来把复杂变简单',\n    '把 bug 关进笼子里',\n    '写到一半，先保存',\n    '把每一步都做成可回滚',\n    '今天的进度，明天的底气',\n    '先收敛，再发散',\n    '让系统变得更可解释',\n    '稳住，我们能赢'\n  ],\n  researching: [\n    '我在挖证据链',\n    '让我把信息熬成结论',\n    '找到了：关键在这里',\n    '先把变量控制住',\n    '我在查：它为什么会这样',\n    '把直觉写成验证',\n    '先定位，再优化',\n    '别急，先画因果图'\n  ],\n  executing: [\n    '执行中：不要眨眼',\n    '把任务切成小块逐个击破',\n    '开始跑 pipeline',\n    '一键推进：走你',\n    '让结果自己说话',\n    '先做最小可行，再做最美版本'\n  ],\n  syncing: [\n    '同步中：把今天锁进云里',\n    '备份不是仪式，是安全感',\n    '写入中…别断电',\n    '把变更交给时间戳',\n    '云端对齐：咔哒',\n    '同步完成前先别乱动',\n    '把未来的自己从灾难里救出来',\n    '多一份备份，少一份后悔'\n  ],\n  error: [\n    '警报响了：先别慌',\n    '我闻到 bug 的味道了',\n    '先复现，再谈修复',\n    '把日志给我，我会说人话',\n    '错误不是敌人，是线索',\n    '把影响面圈起来',\n    '先止血，再手术',\n    '我在：马上定位根因',\n    '别怕，这种我见多了',\n    '报警中：让问题自己现形'\n  ],\n  cat: [\n    '喵~',\n    '咕噜咕噜…',\n    '尾巴摇一摇',\n    '晒太阳最开心',\n    '有人来看我啦',\n    '我是这个办公室的吉祥物',\n    '伸个懒腰',\n    '今天的罐罐准备好了吗',\n    '呼噜呼噜',\n    '这个位置视野最好'\n  ]\n};\n\nlet game, star, sofa, serverroom, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, catBubble = null;\nlet isMoving = false;\nlet waypoints = [];\nlet lastWanderAt = 0;\nlet coordsOverlay, coordsDisplay, coordsToggle;\nlet showCoords = false;\nconst FETCH_INTERVAL = 2000;\nconst BLINK_INTERVAL = 2500;\nconst BUBBLE_INTERVAL = 8000;\nconst CAT_BUBBLE_INTERVAL = 18000;\nlet lastCatBubble = 0;\nconst TYPEWRITER_DELAY = 50;\nlet agents = {}; // agentId -> sprite/container\nlet lastAgentsFetch = 0;\nconst AGENTS_FETCH_INTERVAL = 2500;\n\n// agent 颜色配置\nconst AGENT_COLORS = {\n  star: 0xffd700,\n  npc1: 0x00aaff,\n  agent_nika: 0xff69b4,\n  default: 0x94a3b8\n};\n\n// agent 名字颜色\nconst NAME_TAG_COLORS = {\n  approved: 0x22c55e,\n  pending: 0xf59e0b,\n  rejected: 0xef4444,\n  offline: 0x64748b,\n  default: 0x1f2937\n};\n\n// breakroom / writing / error 区域的 agent 分布位置（多 agent 时错开）\nconst AREA_POSITIONS = {\n  breakroom: [\n    { x: 620, y: 180 },\n    { x: 560, y: 220 },\n    { x: 680, y: 210 },\n    { x: 540, y: 170 },\n    { x: 700, y: 240 },\n    { x: 600, y: 250 },\n    { x: 650, y: 160 },\n    { x: 580, y: 200 }\n  ],\n  writing: [\n    { x: 760, y: 320 },\n    { x: 830, y: 280 },\n    { x: 690, y: 350 },\n    { x: 770, y: 260 },\n    { x: 850, y: 340 },\n    { x: 720, y: 300 },\n    { x: 800, y: 370 },\n    { x: 750, y: 240 }\n  ],\n  error: [\n    { x: 180, y: 260 },\n    { x: 120, y: 220 },\n    { x: 240, y: 230 },\n    { x: 160, y: 200 },\n    { x: 220, y: 270 },\n    { x: 140, y: 250 },\n    { x: 200, y: 210 },\n    { x: 260, y: 260 }\n  ]\n};\n\n\n// 状态控制栏函数（用于测试）\nfunction setState(state, detail) {\n  fetch('/set_state', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ state, detail })\n  }).then(() => fetchStatus());\n}\n\n// 初始化：先检测 WebP 支持，再启动游戏\nasync function initGame() {\n  try {\n    supportsWebP = await checkWebPSupport();\n  } catch (e) {\n    try {\n      supportsWebP = await checkWebPSupportFallback();\n    } catch (e2) {\n      supportsWebP = false;\n    }\n  }\n\n  console.log('WebP 支持:', supportsWebP);\n  new Phaser.Game(config);\n}\n\nfunction preload() {\n  loadingOverlay = document.getElementById('loading-overlay');\n  loadingProgressBar = document.getElementById('loading-progress-bar');\n  loadingText = document.getElementById('loading-text');\n  loadingProgressContainer = document.getElementById('loading-progress-container');\n\n  // 从 LAYOUT 读取总资源数量（避免 magic number）\n  totalAssets = LAYOUT.totalAssets || 15;\n  loadedAssets = 0;\n\n  this.load.on('filecomplete', () => {\n    updateLoadingProgress();\n  });\n\n  this.load.on('complete', () => {\n    hideLoadingOverlay();\n  });\n\n  this.load.image('office_bg', '/static/office_bg_small' + (supportsWebP ? '.webp' : '.png') + '?v={{VERSION_TIMESTAMP}}');\n  this.load.spritesheet('star_idle', '/static/star-idle-spritesheet' + getExt('star-idle-spritesheet.png'), { frameWidth: 128, frameHeight: 128 });\n  this.load.spritesheet('star_researching', '/static/star-researching-spritesheet' + getExt('star-researching-spritesheet.png'), { frameWidth: 128, frameHeight: 105 });\n\n  this.load.image('sofa_idle', '/static/sofa-idle' + getExt('sofa-idle.png'));\n  this.load.spritesheet('sofa_busy', '/static/sofa-busy-spritesheet' + getExt('sofa-busy-spritesheet.png'), { frameWidth: 256, frameHeight: 256 });\n\n  this.load.spritesheet('plants', '/static/plants-spritesheet' + getExt('plants-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });\n  this.load.spritesheet('posters', '/static/posters-spritesheet' + getExt('posters-spritesheet.png'), { frameWidth: 160, frameHeight: 160 });\n  this.load.spritesheet('coffee_machine', '/static/coffee-machine-spritesheet' + getExt('coffee-machine-spritesheet.png'), { frameWidth: 230, frameHeight: 230 });\n  this.load.spritesheet('serverroom', '/static/serverroom-spritesheet' + getExt('serverroom-spritesheet.png'), { frameWidth: 180, frameHeight: 251 });\n\n  this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 180, frameHeight: 180 });\n  this.load.spritesheet('cats', '/static/cats-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 160, frameHeight: 160 });\n  this.load.image('desk', '/static/desk' + getExt('desk.png'));\n  this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 230, frameHeight: 144 });\n  this.load.spritesheet('sync_anim', '/static/sync-animation-spritesheet-grid' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 256, frameHeight: 256 });\n  this.load.image('memo_bg', '/static/memo-bg' + (supportsWebP ? '.webp' : '.png'));\n\n  // 新办公桌：强制 PNG（透明）\n  this.load.image('desk_v2', '/static/desk-v2.png');\n  this.load.spritesheet('flowers', '/static/flowers-spritesheet' + (supportsWebP ? '.webp' : '.png'), { frameWidth: 65, frameHeight: 65 });\n}\n\nfunction create() {\n  game = this;\n  this.add.image(640, 360, 'office_bg');\n\n  // === 沙发（来自 LAYOUT）===\n  sofa = this.add.sprite(\n    LAYOUT.furniture.sofa.x,\n    LAYOUT.furniture.sofa.y,\n    'sofa_busy'\n  ).setOrigin(LAYOUT.furniture.sofa.origin.x, LAYOUT.furniture.sofa.origin.y);\n  sofa.setDepth(LAYOUT.furniture.sofa.depth);\n\n  this.anims.create({\n    key: 'sofa_busy',\n    frames: this.anims.generateFrameNumbers('sofa_busy', { start: 0, end: 47 }),\n    frameRate: 12,\n    repeat: -1\n  });\n\n  areas = LAYOUT.areas;\n\n  this.anims.create({\n    key: 'star_idle',\n    frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: 29 }),\n    frameRate: 12,\n    repeat: -1\n  });\n  this.anims.create({\n    key: 'star_researching',\n    frames: this.anims.generateFrameNumbers('star_researching', { start: 0, end: 95 }),\n    frameRate: 12,\n    repeat: -1\n  });\n\n  star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle');\n  star.setOrigin(0.5);\n  star.setScale(1.4);\n  star.setAlpha(0.95);\n  star.setDepth(20);\n  star.setVisible(false);\n  star.anims.stop();\n\n  if (game.textures.exists('sofa_busy')) {\n    sofa.setTexture('sofa_busy');\n    sofa.anims.play('sofa_busy', true);\n  }\n\n  // === 牌匾（来自 LAYOUT）===\n  const plaqueX = LAYOUT.plaque.x;\n  const plaqueY = LAYOUT.plaque.y;\n  const plaqueBg = game.add.rectangle(plaqueX, plaqueY, LAYOUT.plaque.width, LAYOUT.plaque.height, 0x5d4037);\n  plaqueBg.setStrokeStyle(3, 0x3e2723);\n  const plaqueText = game.add.text(plaqueX, plaqueY, '海辛小龙虾的办公室', {\n    fontFamily: 'ArkPixel, monospace',\n    fontSize: '18px',\n    fill: '#ffd700',\n    fontWeight: 'bold',\n    stroke: '#000',\n    strokeThickness: 2\n  }).setOrigin(0.5);\n  game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);\n  game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);\n\n  // === 植物们（来自 LAYOUT）===\n  const plantFrameCount = 16;\n  for (let i = 0; i < LAYOUT.furniture.plants.length; i++) {\n    const p = LAYOUT.furniture.plants[i];\n    const randomPlantFrame = Math.floor(Math.random() * plantFrameCount);\n    const plant = game.add.sprite(p.x, p.y, 'plants', randomPlantFrame).setOrigin(0.5);\n    plant.setDepth(p.depth);\n    plant.setInteractive({ useHandCursor: true });\n    window[`plantSprite${i === 0 ? '' : i + 1}`] = plant;\n    plant.on('pointerdown', (() => {\n      const next = Math.floor(Math.random() * plantFrameCount);\n      plant.setFrame(next);\n    }));\n  }\n\n  // === 海报（来自 LAYOUT）===\n  const postersFrameCount = 32;\n  const randomPosterFrame = Math.floor(Math.random() * postersFrameCount);\n  const poster = game.add.sprite(LAYOUT.furniture.poster.x, LAYOUT.furniture.poster.y, 'posters', randomPosterFrame).setOrigin(0.5);\n  poster.setDepth(LAYOUT.furniture.poster.depth);\n  poster.setInteractive({ useHandCursor: true });\n  window.posterSprite = poster;\n  window.posterFrameCount = postersFrameCount;\n  poster.on('pointerdown', () => {\n    const next = Math.floor(Math.random() * window.posterFrameCount);\n    window.posterSprite.setFrame(next);\n  });\n\n  // === 小猫（来自 LAYOUT）===\n  const catsFrameCount = 16;\n  const randomCatFrame = Math.floor(Math.random() * catsFrameCount);\n  const cat = game.add.sprite(LAYOUT.furniture.cat.x, LAYOUT.furniture.cat.y, 'cats', randomCatFrame).setOrigin(LAYOUT.furniture.cat.origin.x, LAYOUT.furniture.cat.origin.y);\n  cat.setDepth(LAYOUT.furniture.cat.depth);\n  cat.setInteractive({ useHandCursor: true });\n  window.catSprite = cat;\n  window.catsFrameCount = catsFrameCount;\n  cat.on('pointerdown', () => {\n    const next = Math.floor(Math.random() * window.catsFrameCount);\n    window.catSprite.setFrame(next);\n  });\n\n  // === 咖啡机（来自 LAYOUT）===\n  this.anims.create({\n    key: 'coffee_machine',\n    frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: 95 }),\n    frameRate: 12.5,\n    repeat: -1\n  });\n  const coffeeMachine = this.add.sprite(\n    LAYOUT.furniture.coffeeMachine.x,\n    LAYOUT.furniture.coffeeMachine.y,\n    'coffee_machine'\n  ).setOrigin(LAYOUT.furniture.coffeeMachine.origin.x, LAYOUT.furniture.coffeeMachine.origin.y);\n  coffeeMachine.setDepth(LAYOUT.furniture.coffeeMachine.depth);\n  coffeeMachine.anims.play('coffee_machine', true);\n\n  // === 服务器区（来自 LAYOUT）===\n  this.anims.create({\n    key: 'serverroom_on',\n    frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: 39 }),\n    frameRate: 6,\n    repeat: -1\n  });\n  serverroom = this.add.sprite(\n    LAYOUT.furniture.serverroom.x,\n    LAYOUT.furniture.serverroom.y,\n    'serverroom',\n    0\n  ).setOrigin(LAYOUT.furniture.serverroom.origin.x, LAYOUT.furniture.serverroom.origin.y);\n  serverroom.setDepth(LAYOUT.furniture.serverroom.depth);\n  serverroom.anims.stop();\n  serverroom.setFrame(0);\n\n  // === 新办公桌（来自 LAYOUT，强制透明 PNG）===\n  const desk = this.add.image(\n    LAYOUT.furniture.desk.x,\n    LAYOUT.furniture.desk.y,\n    'desk_v2'\n  ).setOrigin(LAYOUT.furniture.desk.origin.x, LAYOUT.furniture.desk.origin.y);\n  desk.setDepth(LAYOUT.furniture.desk.depth);\n\n  // === 花盆（来自 LAYOUT）===\n  const flowerFrameCount = 16;\n  const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount);\n  const flower = this.add.sprite(\n    LAYOUT.furniture.flower.x,\n    LAYOUT.furniture.flower.y,\n    'flowers',\n    randomFlowerFrame\n  ).setOrigin(LAYOUT.furniture.flower.origin.x, LAYOUT.furniture.flower.origin.y);\n  flower.setScale(LAYOUT.furniture.flower.scale || 1);\n  flower.setDepth(LAYOUT.furniture.flower.depth);\n  flower.setInteractive({ useHandCursor: true });\n  window.flowerSprite = flower;\n  window.flowerFrameCount = flowerFrameCount;\n  flower.on('pointerdown', () => {\n    const next = Math.floor(Math.random() * window.flowerFrameCount);\n    window.flowerSprite.setFrame(next);\n  });\n\n  // === Star 在桌前工作（来自 LAYOUT）===\n  this.anims.create({\n    key: 'star_working',\n    frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 191 }),\n    frameRate: 12,\n    repeat: -1\n  });\n  this.anims.create({\n    key: 'error_bug',\n    frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 95 }),\n    frameRate: 12,\n    repeat: -1\n  });\n\n  // === 错误 bug（来自 LAYOUT）===\n  const errorBug = this.add.sprite(\n    LAYOUT.furniture.errorBug.x,\n    LAYOUT.furniture.errorBug.y,\n    'error_bug',\n    0\n  ).setOrigin(LAYOUT.furniture.errorBug.origin.x, LAYOUT.furniture.errorBug.origin.y);\n  errorBug.setDepth(LAYOUT.furniture.errorBug.depth);\n  errorBug.setVisible(false);\n  errorBug.setScale(LAYOUT.furniture.errorBug.scale);\n  errorBug.anims.play('error_bug', true);\n  window.errorBug = errorBug;\n  window.errorBugDir = 1;\n\n  const starWorking = this.add.sprite(\n    LAYOUT.furniture.starWorking.x,\n    LAYOUT.furniture.starWorking.y,\n    'star_working',\n    0\n  ).setOrigin(LAYOUT.furniture.starWorking.origin.x, LAYOUT.furniture.starWorking.origin.y);\n  starWorking.setVisible(false);\n  starWorking.setScale(LAYOUT.furniture.starWorking.scale);\n  starWorking.setDepth(LAYOUT.furniture.starWorking.depth);\n  window.starWorking = starWorking;\n\n  // === 同步动画（来自 LAYOUT）===\n  this.anims.create({\n    key: 'sync_anim',\n    frames: this.anims.generateFrameNumbers('sync_anim', { start: 1, end: 52 }),\n    frameRate: 12,\n    repeat: -1\n  });\n  syncAnimSprite = this.add.sprite(\n    LAYOUT.furniture.syncAnim.x,\n    LAYOUT.furniture.syncAnim.y,\n    'sync_anim',\n    0\n  ).setOrigin(LAYOUT.furniture.syncAnim.origin.x, LAYOUT.furniture.syncAnim.origin.y);\n  syncAnimSprite.setDepth(LAYOUT.furniture.syncAnim.depth);\n  syncAnimSprite.anims.stop();\n  syncAnimSprite.setFrame(0);\n\n  window.starSprite = star;\n\n  statusText = document.getElementById('status-text');\n  coordsOverlay = document.getElementById('coords-overlay');\n  coordsDisplay = document.getElementById('coords-display');\n  coordsToggle = document.getElementById('coords-toggle');\n\n  coordsToggle.addEventListener('click', () => {\n    showCoords = !showCoords;\n    coordsOverlay.style.display = showCoords ? 'block' : 'none';\n    coordsToggle.textContent = showCoords ? '隐藏坐标' : '显示坐标';\n    coordsToggle.style.background = showCoords ? '#e94560' : '#333';\n  });\n\n  game.input.on('pointermove', (pointer) => {\n    if (!showCoords) return;\n    const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x)));\n    const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y)));\n    coordsDisplay.textContent = `${x}, ${y}`;\n    coordsOverlay.style.left = (pointer.x + 18) + 'px';\n    coordsOverlay.style.top = (pointer.y + 18) + 'px';\n  });\n\n  loadMemo();\n  fetchStatus();\n  fetchAgents();\n\n  // 可选调试：仅在显式开启 debug 模式时渲染测试用尼卡 agent\n  let debugAgents = false;\n  try {\n    if (typeof window !== 'undefined') {\n      if (window.STAR_OFFICE_DEBUG_AGENTS === true) {\n        debugAgents = true;\n      } else if (window.location && window.location.search && typeof URLSearchParams !== 'undefined') {\n        const sp = new URLSearchParams(window.location.search);\n        if (sp.get('debugAgents') === '1') {\n          debugAgents = true;\n        }\n      }\n    }\n  } catch (e) {\n    debugAgents = false;\n  }\n\n  if (debugAgents) {\n    const testNika = {\n      agentId: 'agent_nika',\n      name: '尼卡',\n      isMain: false,\n      state: 'writing',\n      detail: '在画像素画...',\n      area: 'writing',\n      authStatus: 'approved',\n      updated_at: new Date().toISOString()\n    };\n    renderAgent(testNika);\n\n    window.testNikaState = 'writing';\n    window.testNikaTimer = setInterval(() => {\n      const states = ['idle', 'writing', 'researching', 'executing'];\n      const areas = { idle: 'breakroom', writing: 'writing', researching: 'writing', executing: 'writing' };\n      window.testNikaState = states[Math.floor(Math.random() * states.length)];\n      const testAgent = {\n        agentId: 'agent_nika',\n        name: '尼卡',\n        isMain: false,\n        state: window.testNikaState,\n        detail: '在画像素画...',\n        area: areas[window.testNikaState],\n        authStatus: 'approved',\n        updated_at: new Date().toISOString()\n      };\n      renderAgent(testAgent);\n    }, 5000);\n  }\n}\n\nfunction update(time) {\n  if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; }\n  if (time - lastAgentsFetch > AGENTS_FETCH_INTERVAL) { fetchAgents(); lastAgentsFetch = time; }\n\n  const effectiveStateForServer = pendingDesiredState || currentState;\n  if (serverroom) {\n    if (effectiveStateForServer === 'idle') {\n      if (serverroom.anims.isPlaying) {\n        serverroom.anims.stop();\n        serverroom.setFrame(0);\n      }\n    } else {\n      if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') {\n        serverroom.anims.play('serverroom_on', true);\n      }\n    }\n  }\n\n  if (window.errorBug) {\n    if (effectiveStateForServer === 'error') {\n      window.errorBug.setVisible(true);\n      if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') {\n        window.errorBug.anims.play('error_bug', true);\n      }\n      const leftX = LAYOUT.furniture.errorBug.pingPong.leftX;\n      const rightX = LAYOUT.furniture.errorBug.pingPong.rightX;\n      const speed = LAYOUT.furniture.errorBug.pingPong.speed;\n      const dir = window.errorBugDir || 1;\n      window.errorBug.x += speed * dir;\n      window.errorBug.y = LAYOUT.furniture.errorBug.y;\n      if (window.errorBug.x >= rightX) {\n        window.errorBug.x = rightX;\n        window.errorBugDir = -1;\n      } else if (window.errorBug.x <= leftX) {\n        window.errorBug.x = leftX;\n        window.errorBugDir = 1;\n      }\n    } else {\n      window.errorBug.setVisible(false);\n      window.errorBug.anims.stop();\n    }\n  }\n\n  if (syncAnimSprite) {\n    if (effectiveStateForServer === 'syncing') {\n      if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n        syncAnimSprite.anims.play('sync_anim', true);\n      }\n    } else {\n      if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n      syncAnimSprite.setFrame(0);\n    }\n  }\n\n  if (time - lastBubble > BUBBLE_INTERVAL) {\n    showBubble();\n    lastBubble = time;\n  }\n  if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) {\n    showCatBubble();\n    lastCatBubble = time;\n  }\n\n  if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) {\n    typewriterText += typewriterTarget[typewriterIndex];\n    statusText.textContent = typewriterText;\n    typewriterIndex++;\n    lastTypewriter = time;\n  }\n\n  moveStar(time);\n}\n\nfunction normalizeState(s) {\n  if (!s) return 'idle';\n  if (s === 'working') return 'writing';\n  if (s === 'run' || s === 'running') return 'executing';\n  if (s === 'sync') return 'syncing';\n  if (s === 'research') return 'researching';\n  return s;\n}\n\nfunction fetchStatus() {\n  fetch('/status')\n    .then(response => response.json())\n    .then(data => {\n      const nextState = normalizeState(data.state);\n      const stateInfo = STATES[nextState] || STATES.idle;\n      const changed = (pendingDesiredState === null) && (nextState !== currentState);\n      const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...');\n      if (changed) {\n        typewriterTarget = nextLine;\n        typewriterText = '';\n        typewriterIndex = 0;\n\n        pendingDesiredState = null;\n        currentState = nextState;\n\n        if (nextState === 'idle') {\n          if (game.textures.exists('sofa_busy')) {\n            sofa.setTexture('sofa_busy');\n            sofa.anims.play('sofa_busy', true);\n          }\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n        } else if (nextState === 'error') {\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n        } else if (nextState === 'syncing') {\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n        } else {\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(true);\n            window.starWorking.anims.play('star_working', true);\n          }\n        }\n\n        if (serverroom) {\n          if (nextState === 'idle') {\n            serverroom.anims.stop();\n            serverroom.setFrame(0);\n          } else {\n            serverroom.anims.play('serverroom_on', true);\n          }\n        }\n\n        if (syncAnimSprite) {\n          if (nextState === 'syncing') {\n            if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n              syncAnimSprite.anims.play('sync_anim', true);\n            }\n          } else {\n            if (syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n            syncAnimSprite.setFrame(0);\n          }\n        }\n      } else {\n        if (!typewriterTarget || typewriterTarget !== nextLine) {\n          typewriterTarget = nextLine;\n          typewriterText = '';\n          typewriterIndex = 0;\n        }\n      }\n    })\n    .catch(error => {\n      typewriterTarget = '连接失败，正在重试...';\n      typewriterText = '';\n      typewriterIndex = 0;\n    });\n}\n\nfunction moveStar(time) {\n  const effectiveState = pendingDesiredState || currentState;\n  const stateInfo = STATES[effectiveState] || STATES.idle;\n  const baseTarget = areas[stateInfo.area] || areas.breakroom;\n\n  const dx = targetX - star.x;\n  const dy = targetY - star.y;\n  const dist = Math.sqrt(dx * dx + dy * dy);\n  const speed = 1.4;\n  const wobble = Math.sin(time / 200) * 0.8;\n\n  if (dist > 3) {\n    star.x += (dx / dist) * speed;\n    star.y += (dy / dist) * speed;\n    star.setY(star.y + wobble);\n    isMoving = true;\n  } else {\n    if (waypoints && waypoints.length > 0) {\n      waypoints.shift();\n      if (waypoints.length > 0) {\n        targetX = waypoints[0].x;\n        targetY = waypoints[0].y;\n        isMoving = true;\n      } else {\n        if (pendingDesiredState !== null) {\n          isMoving = false;\n          currentState = pendingDesiredState;\n          pendingDesiredState = null;\n\n          if (currentState === 'idle') {\n            star.setVisible(false);\n            star.anims.stop();\n            if (window.starWorking) {\n              window.starWorking.setVisible(false);\n              window.starWorking.anims.stop();\n            }\n          } else {\n            star.setVisible(false);\n            star.anims.stop();\n            if (window.starWorking) {\n              window.starWorking.setVisible(true);\n              window.starWorking.anims.play('star_working', true);\n            }\n          }\n        }\n      }\n    } else {\n      if (pendingDesiredState !== null) {\n        isMoving = false;\n        currentState = pendingDesiredState;\n        pendingDesiredState = null;\n\n        if (currentState === 'idle') {\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(false);\n            window.starWorking.anims.stop();\n          }\n          if (game.textures.exists('sofa_busy')) {\n            sofa.setTexture('sofa_busy');\n            sofa.anims.play('sofa_busy', true);\n          }\n        } else {\n          star.setVisible(false);\n          star.anims.stop();\n          if (window.starWorking) {\n            window.starWorking.setVisible(true);\n            window.starWorking.anims.play('star_working', true);\n          }\n          sofa.anims.stop();\n          sofa.setTexture('sofa_idle');\n        }\n      }\n    }\n  }\n}\n\nfunction showBubble() {\n  if (bubble) { bubble.destroy(); bubble = null; }\n  const texts = BUBBLE_TEXTS[currentState] || BUBBLE_TEXTS.idle;\n  if (currentState === 'idle') return;\n\n  let anchorX = star.x;\n  let anchorY = star.y;\n  if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) {\n    anchorX = syncAnimSprite.x;\n    anchorY = syncAnimSprite.y;\n  } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) {\n    anchorX = window.errorBug.x;\n    anchorY = window.errorBug.y;\n  } else if (!star.visible && window.starWorking && window.starWorking.visible) {\n    anchorX = window.starWorking.x;\n    anchorY = window.starWorking.y;\n  }\n\n  const text = texts[Math.floor(Math.random() * texts.length)];\n  const bubbleY = anchorY - 70;\n  const bg = game.add.rectangle(anchorX, bubbleY, text.length * 10 + 20, 28, 0xffffff, 0.95);\n  bg.setStrokeStyle(2, 0x000000);\n  const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '12px', fill: '#000', align: 'center' }).setOrigin(0.5);\n  bubble = game.add.container(0, 0, [bg, txt]);\n  bubble.setDepth(1200);\n  setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000);\n}\n\nfunction showCatBubble() {\n  if (!window.catSprite) return;\n  if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; }\n  const texts = BUBBLE_TEXTS.cat || ['喵~', '咕噜咕噜…'];\n  const text = texts[Math.floor(Math.random() * texts.length)];\n  const anchorX = window.catSprite.x;\n  const anchorY = window.catSprite.y - 60;\n  const bg = game.add.rectangle(anchorX, anchorY, text.length * 10 + 20, 24, 0xfffbeb, 0.95);\n  bg.setStrokeStyle(2, 0xd4a574);\n  const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5);\n  window.catBubble = game.add.container(0, 0, [bg, txt]);\n  window.catBubble.setDepth(2100);\n  setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000);\n}\n\nfunction fetchAgents() {\n  fetch('/agents?t=' + Date.now(), { cache: 'no-store' })\n    .then(response => response.json())\n    .then(data => {\n      if (!Array.isArray(data)) return;\n      // 重置位置计数器\n      // 按区域分配不同位置索引，避免重叠\n      const areaSlots = { breakroom: 0, writing: 0, error: 0 };\n      for (let agent of data) {\n        const area = agent.area || 'breakroom';\n        agent._slotIndex = areaSlots[area] || 0;\n        areaSlots[area] = (areaSlots[area] || 0) + 1;\n        renderAgent(agent);\n      }\n      // 移除不再存在的 agent\n      const currentIds = new Set(data.map(a => a.agentId));\n      for (let id in agents) {\n        if (!currentIds.has(id)) {\n          if (agents[id]) {\n            agents[id].destroy();\n            delete agents[id];\n          }\n        }\n      }\n    })\n    .catch(error => {\n      console.error('拉取 agents 失败:', error);\n    });\n}\n\nfunction getAreaPosition(area, slotIndex) {\n  const positions = AREA_POSITIONS[area] || AREA_POSITIONS.breakroom;\n  const idx = (slotIndex || 0) % positions.length;\n  return positions[idx];\n}\n\nfunction renderAgent(agent) {\n  const agentId = agent.agentId;\n  const name = agent.name || 'Agent';\n  const area = agent.area || 'breakroom';\n  const authStatus = agent.authStatus || 'pending';\n  const isMain = !!agent.isMain;\n\n  // 获取这个 agent 在区域里的位置\n  const pos = getAreaPosition(area, agent._slotIndex || 0);\n  const baseX = pos.x;\n  const baseY = pos.y;\n\n  // 颜色\n  const bodyColor = AGENT_COLORS[agentId] || AGENT_COLORS.default;\n  const nameColor = NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default;\n\n  // 透明度（离线/待批准/拒绝时变半透明）\n  let alpha = 1;\n  if (authStatus === 'pending') alpha = 0.7;\n  if (authStatus === 'rejected') alpha = 0.4;\n  if (authStatus === 'offline') alpha = 0.5;\n\n  if (!agents[agentId]) {\n    // 新建 agent\n    const container = game.add.container(baseX, baseY);\n    container.setDepth(1200 + (isMain ? 100 : 0)); // 放到最顶层！\n\n    // 像素小人：用星星图标，更明显\n    const starIcon = game.add.text(0, 0, '⭐', {\n      fontFamily: 'ArkPixel, monospace',\n      fontSize: '32px'\n    }).setOrigin(0.5);\n    starIcon.name = 'starIcon';\n\n    // 名字标签（漂浮）\n    const nameTag = game.add.text(0, -36, name, {\n      fontFamily: 'ArkPixel, monospace',\n      fontSize: '14px',\n      fill: '#' + nameColor.toString(16).padStart(6, '0'),\n      stroke: '#000',\n      strokeThickness: 3,\n      backgroundColor: 'rgba(255,255,255,0.95)'\n    }).setOrigin(0.5);\n    nameTag.name = 'nameTag';\n\n    // 状态小点（绿色/黄色/红色）\n    let dotColor = 0x64748b;\n    if (authStatus === 'approved') dotColor = 0x22c55e;\n    if (authStatus === 'pending') dotColor = 0xf59e0b;\n    if (authStatus === 'rejected') dotColor = 0xef4444;\n    if (authStatus === 'offline') dotColor = 0x94a3b8;\n    const statusDot = game.add.circle(20, -20, 5, dotColor, alpha);\n    statusDot.setStrokeStyle(2, 0x000000, alpha);\n    statusDot.name = 'statusDot';\n\n    container.add([starIcon, statusDot, nameTag]);\n    agents[agentId] = container;\n  } else {\n    // 更新 agent\n    const container = agents[agentId];\n    container.setPosition(baseX, baseY);\n    container.setAlpha(alpha);\n    container.setDepth(1200 + (isMain ? 100 : 0));\n\n    // 更新名字和颜色（如果变化）\n    const nameTag = container.getAt(2);\n    if (nameTag && nameTag.name === 'nameTag') {\n      nameTag.setText(name);\n      nameTag.setFill('#' + (NAME_TAG_COLORS[authStatus] || NAME_TAG_COLORS.default).toString(16).padStart(6, '0'));\n    }\n    // 更新状态点颜色\n    const statusDot = container.getAt(1);\n    if (statusDot && statusDot.name === 'statusDot') {\n      let dotColor = 0x64748b;\n      if (authStatus === 'approved') dotColor = 0x22c55e;\n      if (authStatus === 'pending') dotColor = 0xf59e0b;\n      if (authStatus === 'rejected') dotColor = 0xef4444;\n      if (authStatus === 'offline') dotColor = 0x94a3b8;\n      statusDot.fillColor = dotColor;\n    }\n  }\n}\n\n// 启动游戏\ninitGame();\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>Star 的像素办公室</title>\n    <style>\n        @font-face {\n            font-family: 'ArkPixel';\n            src: url('/static/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2') format('woff2');\n            font-weight: normal;\n            font-style: normal;\n        }\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        body {\n            background: #1a1a2e;\n            display: flex;\n            flex-direction: column;\n            justify-content: flex-start;\n            align-items: center;\n            min-height: 100vh;\n            font-family: 'ArkPixel', 'Courier New', monospace;\n            padding: 20px 0;\n            gap: 10px;\n            overflow-x: hidden;\n        }\n        /* 底部面板容器 */\n        #main-stage {\n            position: relative;\n            width: 1280px;\n            transition: transform .25s ease;\n            will-change: transform;\n        }\n        body.drawer-open #main-stage {\n            /* 与右侧抽屉并列：按视口动态左移，确保与抽屉至少保留 20px 间隔 */\n            transform: translateX(calc(-1 * max(0px, (min(320px, 92vw) + 20px) - ((100vw - 1280px) / 2))));\n        }\n        #bottom-panels {\n            display: flex;\n            gap: 20px;\n            width: 1280px;\n            max-width: none;\n            justify-content: flex-start;\n            margin-top: 20px;\n        }\n        #game-container {\n            position: relative;\n            border: 0;\n            image-rendering: pixelated;\n            width: 1280px;\n            height: 720px;\n            max-width: none;\n            max-height: none;\n            aspect-ratio: auto;\n            overflow: hidden;\n        }\n        #game-container canvas {\n            width: 100% !important;\n            height: 100% !important;\n            image-rendering: pixelated;\n            /* 再兜底一次：即使外层高度变化，也不要拉伸变形 */\n            object-fit: contain;\n            /* 边框改为直接贴合画布内部，避免“框比地图大” */\n            box-shadow: inset 0 0 0 4px #64477d;\n            position: relative;\n            z-index: 10;\n        }\n        /* 首屏骨架：避免 Phaser 未就绪时纯黑屏 */\n        #game-skeleton {\n            position: absolute;\n            inset: 0;\n            z-index: 5;\n            background: radial-gradient(ellipse at center, #2a2a45 0%, #1a1a2e 65%, #151522 100%);\n            overflow: hidden;\n        }\n        #game-skeleton::before {\n            content: '';\n            position: absolute;\n            inset: 0;\n            background: repeating-linear-gradient(\n                0deg,\n                rgba(255,255,255,0.03) 0px,\n                rgba(255,255,255,0.03) 1px,\n                transparent 1px,\n                transparent 12px\n            );\n            opacity: .6;\n        }\n        #game-skeleton .hint {\n            position: absolute;\n            left: 50%;\n            top: 50%;\n            transform: translate(-50%, -50%);\n            color: #cbd5e1;\n            font-size: 14px;\n            letter-spacing: 1px;\n            text-shadow: 0 1px 0 rgba(0,0,0,.4);\n        }\n        #loading-overlay {\n            position: fixed;\n            top: 0;\n            left: 0;\n            width: 100%;\n            height: 100%;\n            background: #1a1a2e;\n            display: flex;\n            flex-direction: column;\n            justify-content: center;\n            align-items: center;\n            z-index: 100000;\n        }\n        #loading-text {\n            color: #ffd700;\n            font-size: 18px;\n            margin-bottom: 20px;\n        }\n        #loading-progress-container {\n            width: 300px;\n            height: 20px;\n            background: #333;\n            border: 2px solid #555;\n            border-radius: 4px;\n        }\n        #loading-progress-bar {\n            height: 100%;\n            background: linear-gradient(90deg, #e94560, #ffd700);\n            width: 0%;\n            transition: width 0.3s ease;\n        }\n        #status-text {\n            position: absolute;\n            bottom: 12px;\n            left: 12px;\n            transform: none;\n            color: #eee;\n            font-size: 14px;\n            background: rgba(0,0,0,0.7);\n            padding: 8px 12px;\n            border-radius: 4px;\n            max-width: calc(100% - 24px);\n            text-align: left;\n            font-family: 'ArkPixel', 'Courier New', monospace;\n            z-index: 30;\n            pointer-events: none;\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n            line-height: 1.2;\n        }\n        /* 状态控制栏 */\n        #control-bar {\n            position: relative;\n            background: #141722;\n            padding: 10px 10px 12px;\n            border-radius: 0;\n            border: 4px solid #0e1119;\n            box-shadow: none;\n            width: 390px;\n            height: 300px;\n            display: flex;\n            flex-direction: column;\n            gap: 10px;\n            overflow: hidden;\n        }\n        #control-bar::before {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f);\n            background-repeat: no-repeat;\n            background-size:\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px),\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px);\n            background-position:\n                9px 8px, calc(50% + 5px) 8px,\n                8px 9px, calc(100% - 10px) 9px,\n                9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),\n                8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);\n        }\n        #control-bar::after {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f);\n            background-repeat: no-repeat;\n            background-size:\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px;\n            background-position:\n                left top, left top,\n                right top, right top,\n                left bottom, left bottom,\n                right bottom, right bottom;\n        }\n        #control-bar-title {\n            color: #ffd700;\n            font-size: 16px;\n            font-weight: bold;\n            text-align: center;\n            letter-spacing: 1px;\n            padding: 6px 0 10px;\n            border-bottom: 0;\n        }\n        #control-buttons {\n            display: grid;\n            grid-template-columns: repeat(4, minmax(0, 1fr));\n            gap: 8px;\n            align-content: start;\n            padding-top: 4px;\n            padding-left: 10px;\n            padding-right: 10px;\n            box-sizing: border-box;\n        }\n        #btn-open-drawer {\n            grid-column: 1 / -1;\n            background: #78a340;\n            border-color: #8fbe4a;\n            color: #f3ffe6;\n            font-weight: 700;\n        }\n\n        #asset-drawer-backdrop {\n            position: fixed;\n            inset: 0;\n            background: rgba(0, 0, 0, 0.5);\n            z-index: 1000005;\n            display: none;\n            -webkit-tap-highlight-color: transparent;\n        }\n        #asset-drawer-backdrop.open {\n            display: block;\n        }\n\n        #asset-drawer {\n            position: fixed;\n            top: 0;\n            right: -100vw;\n            width: 320px;\n            max-width: 92vw;\n            height: 100vh;\n            height: 100dvh;\n            background: #111827;\n            border-left: 2px solid #22c55e;\n            box-shadow: -8px 0 24px rgba(0,0,0,0.45);\n            transition: right 0.25s ease;\n            z-index: 1000010;\n            display: flex;\n            flex-direction: column;\n            overscroll-behavior: contain;\n        }\n        #asset-drawer.open { right: 0; }\n        #asset-drawer-header {\n            color: #ecfdf5;\n            font-size: 15px;\n            padding: 12px;\n            border-bottom: 1px solid rgba(148, 163, 184, 0.22);\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n            background: #0b1220;\n        }\n        #asset-drawer-body {\n            padding: 10px;\n            overflow: auto;\n            color: #e5e7eb;\n            font-size: 12px;\n            position: relative;\n            display: flex;\n            flex-direction: column;\n            min-height: 0;\n        }\n        .asset-toolbar { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }\n        .asset-toolbar input { flex:1; min-width: 150px; padding:6px 8px; border-radius:6px; border:1px solid rgba(148, 163, 184, 0.22); background:#1f2937; color:#fff; }\n        .asset-toolbar button, #asset-drawer-header button { cursor:pointer; border:1px solid rgba(148, 163, 184, 0.24); background:#1f2937; color:#fff; border-radius:6px; padding:6px 8px; font-family:'ArkPixel', monospace; }\n        .asset-toolbar button:hover, #asset-drawer-header button:hover { border-color:#64748b; }\n        #asset-list {\n            display:flex;\n            flex-direction:column;\n            gap:6px;\n            flex: 1 1 auto;\n            min-height: 120px;\n            max-height: none;\n            overflow-y: auto;\n            padding-right: 2px;\n            scrollbar-color: #1f2937 #0b1220;\n            scrollbar-width: thin;\n        }\n        #asset-list::-webkit-scrollbar { width: 8px; }\n        #asset-list::-webkit-scrollbar-track { background: #0b1220; }\n        #asset-list::-webkit-scrollbar-thumb { background: #1f2937; border-radius: 0; border: 1px solid #111827; }\n        #asset-upload-panel {\n            position: sticky;\n            left: 8px;\n            right: 8px;\n            width: auto;\n            max-width: none;\n            box-sizing: border-box;\n            margin-top: 10px;\n            margin-bottom: 0;\n            bottom: 8px;\n            background: rgba(11, 18, 32, 0.96);\n            border: 1px solid rgba(71, 85, 105, 0.75);\n            border-radius: 10px;\n            padding: 10px;\n            z-index: 1000030;\n            display: none;\n            box-shadow: 0 12px 30px rgba(0,0,0,.45);\n            backdrop-filter: blur(2px);\n        }\n        #asset-upload-panel.active {\n            display: block;\n        }\n        .asset-upload-grid {\n            display: grid;\n            grid-template-columns: 1fr 1fr;\n            gap: 8px;\n        }\n        .asset-upload-grid > button {\n            width: 100%;\n            min-height: 36px;\n            padding: 6px 8px;\n            white-space: nowrap;\n            overflow: hidden;\n            text-overflow: ellipsis;\n            background: #111827;\n            color: #e5e7eb;\n            border: 1px solid #334155;\n        }\n        .asset-upload-grid > button:hover {\n            background: #1f2937;\n            border-color: #475569;\n            color: #f8fafc;\n        }\n        .asset-item {\n            border: 1px solid rgba(148, 163, 184, 0.16);\n            background: #0f172a;\n            border-radius: 8px;\n            padding: 8px;\n            display: grid;\n            grid-template-columns: 56px 1fr 44px;\n            gap: 8px;\n            align-items: center;\n            cursor: pointer;\n            transition: border-color .12s ease, background-color .12s ease;\n        }\n        .asset-item:hover { border-color: rgba(148, 163, 184, 0.34); background:#111b2f; }\n        .asset-item.active { border-color: #22c55e; box-shadow: 0 0 0 1px rgba(34,197,94,.55) inset; }\n        .asset-vis-btn {\n            min-width: 34px;\n            height: 28px;\n            padding: 2px 4px;\n            border: 1px solid rgba(148, 163, 184, 0.16);\n            background: rgba(15, 23, 42, 0.66);\n            color: #d1d5db;\n            border-radius: 999px;\n            font-size: 14px;\n            cursor: pointer;\n            font-family:'ArkPixel', monospace;\n        }\n        .asset-vis-btn:hover { border-color:rgba(148, 163, 184, 0.38); color:#ecfccb; background:rgba(30,41,59,.92); }\n        .asset-thumb { width:56px; height:56px; object-fit: contain; background:#0b1220; border:1px solid rgba(148, 163, 184, 0.16); border-radius:6px; }\n        .asset-meta { line-height: 1.45; }\n        .asset-path { color:#d1fae5; word-break: break-all; }\n        .asset-sub { color:#9ca3af; font-size:11px; }\n        #asset-upload-result { white-space: normal; line-height: 1.5; }\n        #asset-upload-result .hint-p { margin: 0 0 6px 0; }\n        #asset-upload-result .hint-p:last-child { margin-bottom: 0; }\n        .asset-plus-box { width:100%; height:92px; border:2px dashed #4b5563; border-radius:8px; display:flex; align-items:center; justify-content:center; color:#9ca3af; font-size:34px; cursor:pointer; user-select:none; }\n        .asset-plus-box:hover { border-color:#22c55e; color:#22c55e; }\n        .asset-preview-box { border:1px solid rgba(148, 163, 184, 0.10); border-radius:8px; padding:6px; background:#0b1220; margin-bottom:8px; }\n        .asset-preview-title { color:#9ca3af; font-size:11px; margin-bottom:4px; }\n        #asset-auth-gate {\n            border: 0;\n            border-bottom: 1px solid rgba(148, 163, 184, 0.18);\n            border-radius: 0;\n            padding: 0 0 10px;\n            margin-bottom: 10px;\n            background: transparent;\n        }\n        #asset-home-favorites {\n            border: 0;\n            border-top: 1px solid rgba(148, 163, 184, 0.16);\n            border-radius: 0;\n            background: transparent;\n            padding: 6px 0 4px;\n            margin: 0;\n        }\n        #asset-home-favorites-title {\n            font-size: 12px;\n            line-height: 1.35;\n            margin-bottom: 4px;\n            display: flex;\n            align-items: center;\n            min-height: 20px;\n        }\n        .asset-preview-img { width:100%; height:92px; object-fit:contain; background:#111827; border:1px solid rgba(148, 163, 184, 0.14); border-radius:6px; }\n        .home-fav-list { display:flex; gap:8px; overflow-x:auto; padding-bottom:4px; }\n        .home-fav-item { min-width:126px; max-width:126px; border:0; border-radius:8px; background:#111827; padding:6px; box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.12); }\n        .home-fav-item img { width:100%; height:70px; object-fit:cover; border:1px solid rgba(148, 163, 184, 0.14); border-radius:6px; image-rendering:pixelated; }\n        .home-fav-meta { color:#9ca3af; font-size:10px; margin-top:4px; line-height:1.3; min-height:24px; }\n        .home-fav-item button { width:100%; margin-top:4px; border:1px solid rgba(148, 163, 184, 0.20); background:#1f2937; color:#fff; border-radius:6px; padding:4px 6px; font-family:'ArkPixel', monospace; cursor:pointer; }\n        .home-fav-item button:hover { border-color:#64748b; }\n        .home-fav-del { background:#2a1416 !important; border-color: rgba(248,113,113,.35) !important; color:#fecaca !important; }\n        .home-fav-del:hover { border-color: rgba(248,113,113,.55) !important; }\n        #gemini-api-doc-link { color:#86efac; text-decoration: underline; text-underline-offset: 2px; }\n        #gemini-api-doc-link:hover { color:#bbf7d0; }\n\n\n        #asset-move-panel { border:0; background:transparent; border-radius:0; padding:6px 0 8px; margin-bottom:6px; border-bottom: 1px solid rgba(148, 163, 184, 0.18); display:flex; flex-direction:column; justify-content:center; gap:6px; min-height:132px; }\n        #asset-home-actions-panel { border:0; background:transparent; border-radius:0; padding:6px 0 8px; border-bottom: 1px solid rgba(148, 163, 184, 0.18); }\n        #asset-manual-panel {\n            border-top: 0;\n            padding-top: 6px;\n            margin-top: 6px;\n        }\n        #asset-home-actions-panel .asset-toolbar,\n        #asset-move-row,\n        #asset-broker-row {\n            display:grid;\n            grid-template-columns: 1fr 1fr;\n            gap:8px;\n            margin:0;\n            align-items:center;\n        }\n        #asset-home-actions-panel .asset-toolbar > button { width:100%; margin:0; min-height:42px; }\n        #asset-move-row .btn-move,\n        #asset-move-row .btn-home,\n        #asset-broker-row .btn-broker,\n        #asset-broker-row .btn-diy {\n            width:100%;\n            min-width:0;\n            height:42px;\n            padding:8px 8px 0;\n            border:none;\n            border-radius:0;\n            background-color: transparent !important;\n            background-repeat:no-repeat;\n            background-size:300% 100%;\n            background-position:0 0;\n            image-rendering: pixelated;\n            appearance:none;\n            -webkit-appearance:none;\n            color:#fff;\n            text-align:center;\n            font-size:14px;\n            font-weight:400;\n            letter-spacing:.2px;\n            text-shadow:none;\n            display:inline-flex;\n            align-items:flex-start;\n            justify-content:center;\n            transition: padding-top .08s ease, filter .12s ease;\n            box-shadow:none;\n        }\n        #asset-move-row .btn-move { color:#1f2937; }\n        #asset-move-row .btn-move {\n            background-image:url('/static/btn-move-house-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-move-row .btn-home {\n            background-image:url('/static/btn-back-home-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-broker-row .btn-broker {\n            background-image:url('/static/btn-broker-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-broker-row .btn-diy {\n            background-image:url('/static/btn-diy-sprite.png?v={{VERSION_TIMESTAMP}}');\n        }\n        #asset-manual-panel {\n            margin-top:0;\n            max-height:0;\n            opacity:0;\n            transform:translateY(-6px);\n            overflow:hidden;\n            pointer-events:none;\n            transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease;\n        }\n        #asset-manual-panel.open {\n            margin-top:8px;\n            max-height:1600px;\n            opacity:1;\n            transform:translateY(0);\n            pointer-events:auto;\n        }\n        #asset-broker-panel {\n            margin-top:0;\n            border:1px dashed #334155;\n            border-radius:8px;\n            padding:8px;\n            background:#0f172a;\n            max-height:0;\n            opacity:0;\n            transform:translateY(-6px);\n            overflow:hidden;\n            pointer-events:none;\n            transition:max-height .28s ease, opacity .22s ease, transform .28s ease, margin-top .28s ease;\n        }\n        #asset-broker-panel.open {\n            margin-top:8px;\n            max-height:520px;\n            opacity:1;\n            transform:translateY(0);\n            pointer-events:auto;\n        }\n        #asset-broker-prompt {\n            width:100%; min-height:66px; resize:vertical;\n            padding:8px; border-radius:6px;\n            border:1px solid #334155; background:#111827; color:#e5e7eb;\n            font-family:'ArkPixel', monospace; font-size:12px;\n            box-sizing:border-box;\n        }\n        #asset-broker-actions { margin-top:8px; display:flex; justify-content:flex-end; }\n        #asset-broker-actions button {\n            background:#0ea5e9;\n            color:#e0f2fe;\n            border-color:#38bdf8;\n            font-weight:700;\n            font-size:12px;\n            padding:7px 10px;\n            min-width:112px;\n            text-align:center;\n            box-shadow: 0 2px 0 rgba(0,0,0,.25);\n            transition: transform .08s ease, filter .12s ease, box-shadow .12s ease;\n        }\n        #asset-move-row .btn-move:hover,\n        #asset-move-row .btn-home:hover,\n        #asset-broker-row .btn-broker:hover,\n        #asset-broker-row .btn-diy:hover,\n        #asset-broker-actions button:hover {\n            filter: brightness(1.06);\n            background-color: transparent !important;\n        }\n        #asset-move-row .btn-move:active,\n        #asset-move-row .btn-home:active,\n        #asset-broker-row .btn-broker:active,\n        #asset-broker-row .btn-diy:active,\n        #asset-broker-actions button:active,\n        #asset-move-row .btn-move.is-active,\n        #asset-move-row .btn-home.is-active,\n        #asset-broker-row .btn-broker.is-active,\n        #asset-broker-row .btn-diy.is-active,\n        #asset-broker-actions button.is-active {\n            padding-top:13px;\n            filter: brightness(0.96);\n            background-color: transparent !important;\n        }\n\n        #asset-move-row .btn-move:active,\n        #asset-move-row .btn-move.is-active,\n        #asset-move-row .btn-home:active,\n        #asset-move-row .btn-home.is-active,\n        #asset-broker-row .btn-broker:active,\n        #asset-broker-row .btn-broker.is-active,\n        #asset-broker-row .btn-diy:active,\n        #asset-broker-row .btn-diy.is-active {\n            background-position:50% 0;\n        }\n        #asset-move-row .btn-move.is-done,\n        #asset-move-row .btn-home.is-done,\n        #asset-broker-row .btn-broker.is-done,\n        #asset-broker-row .btn-diy.is-done {\n            background-position:100% 0;\n        }\n\n\n        #asset-highlight {\n            position: fixed;\n            border: 3px solid #22c55e;\n            background: transparent;\n            box-shadow: none;\n            pointer-events: none;\n            display: none;\n            z-index: 999998;\n        }\n        #room-loading-overlay {\n            position: fixed;\n            left: 0;\n            top: 0;\n            width: 0;\n            height: 0;\n            background: rgba(0, 0, 0, 0.62);\n            z-index: 1000000;\n            display: none;\n            align-items: center;\n            justify-content: center;\n            pointer-events: auto;\n            border-radius: 10px;\n        }\n        #lang-toggle-group,\n        #lang-toggle-group button {\n            position: relative;\n            z-index: 1000002 !important;\n        }\n        .room-loading-inner {\n            display: flex;\n            flex-direction: column;\n            align-items: center;\n            gap: 10px;\n            padding: 16px 20px;\n            border-radius: 10px;\n            border: 1px solid rgba(255,255,255,.2);\n            background: rgba(0,0,0,.36);\n            color: #fff;\n            font-family: 'ArkPixel', monospace;\n            font-size: 20px;\n            text-shadow: 0 2px 6px rgba(0,0,0,.45);\n        }\n        #room-loading-emoji {\n            font-size: 52px;\n            line-height: 1;\n            min-height: 56px;\n        }\n        #room-loading-text {\n            font-size: 20px;\n            letter-spacing: 1px;\n        }\n        #control-buttons button { height: 52px; }\n        #control-bar button {\n            background: #3a3f4f;\n            color: #fff;\n            border: 2px solid #555;\n            border-radius: 4px;\n            padding: 8px 10px;\n            cursor: pointer;\n            font-family: 'ArkPixel', monospace;\n            font-size: 12px;\n            transition: all 0.2s;\n        }\n        #control-bar button:hover {\n            background: #4a4f5f;\n            border-color: #e94560;\n        }\n        /* Star 状态四按钮（不含装修）使用像素精灵皮肤 */\n        #control-bar #btn-state-idle,\n        #control-bar #btn-state-writing,\n        #control-bar #btn-state-syncing,\n        #control-bar #btn-state-error {\n            background-image: url('/static/btn-state-sprite.png?v={{VERSION_TIMESTAMP}}');\n            background-color: transparent !important;\n            background-repeat: no-repeat;\n            background-size: 300% 100%;\n            background-position: 0 0;\n            border: none;\n            border-radius: 0;\n            appearance: none;\n            -webkit-appearance: none;\n            image-rendering: pixelated;\n            color: #5e6366;\n            font-weight: 400;\n            text-shadow: none;\n            padding: 0 8px 9px;\n            line-height: 1;\n            transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease;\n        }\n        #control-bar #btn-state-idle:hover,\n        #control-bar #btn-state-writing:hover,\n        #control-bar #btn-state-syncing:hover,\n        #control-bar #btn-state-error:hover {\n            background-color: transparent !important;\n            filter: brightness(1.04);\n        }\n        #control-bar #btn-state-idle:active,\n        #control-bar #btn-state-writing:active,\n        #control-bar #btn-state-syncing:active,\n        #control-bar #btn-state-error:active {\n            background-position: 50% 0;\n            padding-top: 5px;\n            padding-bottom: 0;\n            filter: brightness(0.97);\n        }\n        /* 装修房间按钮使用像素精灵皮肤 */\n        #control-bar #btn-open-drawer {\n            background-image: url('/static/btn-open-drawer-sprite.png?v={{VERSION_TIMESTAMP}}') !important;\n            background-color: transparent !important;\n            background-repeat: no-repeat !important;\n            background-size: 300% 100% !important;\n            background-position: 0 0 !important;\n            border: none !important;\n            border-radius: 0 !important;\n            appearance: none;\n            -webkit-appearance: none;\n            image-rendering: pixelated;\n            color: #5e6366 !important;\n            font-weight: 400 !important;\n            font-size: 15px !important;\n            text-shadow: none !important;\n            padding: 0 10px 10px !important;\n            line-height: 1 !important;\n            transition: padding-top .08s ease, padding-bottom .08s ease, filter .12s ease;\n        }\n        #control-bar #btn-open-drawer:hover {\n            background-color: transparent !important;\n            filter: brightness(1.04);\n        }\n        #control-bar #btn-open-drawer:active {\n            background-position: 50% 0 !important;\n            padding-top: 5px !important;\n            padding-bottom: 5px !important;\n            filter: brightness(0.97);\n        }\n        /* Guest Agent 名单面板（右下角） */\n        #guest-agent-panel {\n            position: relative;\n            width: 390px;\n            height: 300px;\n            background: #141722;\n            padding: 10px 10px 12px;\n            border-radius: 0;\n            border: 4px solid #0e1119;\n            box-shadow: none;\n            display: flex;\n            flex-direction: column;\n            gap: 10px;\n            overflow: hidden;\n        }\n        #guest-agent-panel::before {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f);\n            background-repeat: no-repeat;\n            background-size:\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px),\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px);\n            background-position:\n                9px 8px, calc(50% + 5px) 8px,\n                8px 9px, calc(100% - 10px) 9px,\n                9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),\n                8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);\n        }\n        #guest-agent-panel::after {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f);\n            background-repeat: no-repeat;\n            background-size:\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px;\n            background-position:\n                left top, left top,\n                right top, right top,\n                left bottom, left bottom,\n                right bottom, right bottom;\n        }\n        #guest-agent-panel-title {\n            color: #ffd700;\n            font-size: 16px;\n            font-weight: bold;\n            text-align: center;\n            letter-spacing: 1px;\n            padding: 6px 0 10px;\n            border-bottom: 0;\n            margin-bottom: 0;\n        }\n        #guest-agent-list {\n            flex-grow: 1;\n            overflow-y: auto;\n            display: flex;\n            flex-direction: column;\n            gap: 8px;\n            padding-right: 4px;\n        }\n        #guest-agent-list::-webkit-scrollbar { width: 6px; }\n        #guest-agent-list::-webkit-scrollbar-track { background: #1a1a2e; }\n        #guest-agent-list::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; }\n        .guest-agent-item {\n            display: flex;\n            align-items: center;\n            justify-content: space-between;\n            gap: 8px;\n            background: #3a3f4f;\n            padding: 8px 10px;\n            border-radius: 6px;\n            border: 1px solid #555;\n        }\n        .guest-agent-name {\n            color: #fff;\n            font-size: 14px;\n            flex-shrink: 0;\n        }\n        .guest-agent-buttons {\n            display: flex;\n            gap: 6px;\n            flex-shrink: 0;\n        }\n        .guest-agent-buttons button {\n            padding: 6px 10px;\n            border-radius: 4px;\n            border: 2px solid #555;\n            background: #4a4f5f;\n            color: #fff;\n            font-family: 'ArkPixel', monospace;\n            font-size: 12px;\n            cursor: pointer;\n            transition: all 0.2s;\n        }\n        .guest-agent-buttons button:hover {\n            background: #5a5f6f;\n            border-color: #e94560;\n        }\n        .guest-agent-buttons button.leave-btn {\n            background: #5a1818;\n            border-color: #e94560;\n        }\n        .guest-agent-buttons button.leave-btn:hover {\n            background: #6a2828;\n        }\n        /* Memo 区域 - 4:3 小正方形 */\n        #memo-panel {\n            position: relative;\n            width: 460px;\n            height: 300px;\n            background-image: url('/static/memo-bg.webp');\n            background-size: cover;\n            background-position: center;\n            border: 4px solid #0e1119;\n            border-radius: 0;\n            padding: 14px 16px;\n            box-shadow: none;\n            display: flex;\n            flex-direction: column;\n            overflow: hidden;\n        }\n        #memo-panel::before {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f);\n            background-repeat: no-repeat;\n            background-size:\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px),\n                calc(50% - 14px) 2px, calc(50% - 14px) 2px,\n                2px calc(50% - 14px), 2px calc(50% - 14px);\n            background-position:\n                9px 8px, calc(50% + 5px) 8px,\n                8px 9px, calc(100% - 10px) 9px,\n                9px calc(100% - 10px), calc(50% + 5px) calc(100% - 10px),\n                8px calc(50% + 5px), calc(100% - 10px) calc(50% + 5px);\n        }\n        #memo-panel::after {\n            content: '';\n            position: absolute;\n            inset: 0;\n            pointer-events: none;\n            background-image:\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f),\n                linear-gradient(#1a1b2f, #1a1b2f), linear-gradient(#1a1b2f, #1a1b2f);\n            background-repeat: no-repeat;\n            background-size:\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px,\n                9px 4px, 4px 9px;\n            background-position:\n                left top, left top,\n                right top, right top,\n                left bottom, left bottom,\n                right bottom, right bottom;\n        }\n        #memo-panel.no-bg {\n            background-image: none !important;\n            background-color: #111827;\n        }\n        #memo-title {\n            color: #1a1b2f;\n            font-size: 16px;\n            font-weight: bold;\n            margin-bottom: 6px;\n            text-align: center;\n            letter-spacing: 1px;\n            flex-shrink: 0;\n            position: relative;\n            top: 15px;\n        }\n        #memo-date {\n            color: #888;\n            font-size: 10px;\n            margin-bottom: 8px;\n            text-align: right;\n            flex-shrink: 0;\n            position: relative;\n            left: -40px; /* move date left by 40px */\n            top: -10px;\n        }\n        #memo-content {\n            color: #3b3b32;\n            font-size: 12px;\n            line-height: 1.8;\n            white-space: pre-wrap;\n            word-wrap: break-word;\n            overflow-y: auto;\n            flex-grow: 1;\n            padding-right: 4px;\n            position: relative;\n            left: 100px; /* move content right by 100px */\n            top: -10px;\n        }\n        #memo-content::-webkit-scrollbar {\n            width: 6px;\n        }\n        #memo-content::-webkit-scrollbar-track {\n            background: #1a1a2e;\n        }\n        #memo-content::-webkit-scrollbar-thumb {\n            background: #444;\n            border-radius: 3px;\n        }\n        #memo-placeholder {\n            color: #666;\n            font-style: italic;\n            text-align: center;\n            padding: 20px 0;\n        }\n        .memo-decoration {\n            text-align: center;\n            margin: 4px 0;\n            color: #555;\n            font-size: 10px;\n            flex-shrink: 0;\n        }\n\n        /* 手机端专属适配（不影响桌面） */\n        @media (max-width: 900px), (pointer: coarse) {\n            html, body {\n                height: 100%;\n            }\n\n            body {\n                padding: 0;\n                gap: 0;\n                overflow-x: auto;\n                overflow-y: auto;\n                -webkit-overflow-scrolling: touch;\n                align-items: stretch;\n            }\n\n            #game-container {\n                width: 100vw;\n                height: 66.666vh; /* 办公室占 2/3 屏幕高度 */\n                max-width: 100vw;\n                max-height: 66.666vh;\n                border-width: 0;\n                border-radius: 0;\n                aspect-ratio: auto;\n                flex: 0 0 auto;\n                touch-action: auto;\n                overflow: hidden;\n            }\n\n            #main-stage {\n                width: 100vw;\n                min-width: 0;\n                margin-left: 0 !important;\n            }\n\n            #bottom-panels {\n                width: 100vw;\n                max-width: 100vw;\n                min-height: 33.334vh; /* 余下约 1/3 可见区 */\n                padding: 10px 10px 16px;\n                display: flex;\n                flex-direction: column;\n                gap: 10px;\n                flex: 0 0 auto;\n            }\n\n            body.drawer-open #main-stage {\n                margin-left: 0 !important;\n            }\n\n            #memo-panel,\n            #control-bar,\n            #guest-agent-panel {\n                width: 100%;\n                height: auto;\n                min-height: 180px;\n            }\n\n            #memo-panel { min-height: 220px; }\n            #control-bar { min-height: 210px; }\n            #guest-agent-panel { min-height: 220px; }\n\n            #memo-date {\n                left: 0;\n                text-align: left;\n                margin-bottom: 6px;\n            }\n            #memo-content {\n                left: 0;\n                font-size: 13px;\n                line-height: 1.7;\n            }\n\n            #control-bar-title,\n            #guest-agent-panel-title,\n            #memo-title {\n                font-size: 14px;\n            }\n\n            #control-buttons button,\n            #control-bar button,\n            .guest-agent-buttons button {\n                font-size: 12px;\n                min-height: 44px;\n            }\n            #control-buttons {\n                grid-template-columns: repeat(4, minmax(0, 1fr));\n                gap: 6px;\n            }\n            #control-buttons button {\n                min-height: 40px;\n                padding: 4px 2px;\n                font-size: 11px;\n            }\n\n            .guest-agent-item {\n                align-items: flex-start;\n                gap: 10px;\n                flex-direction: column;\n            }\n            .guest-agent-buttons {\n                width: 100%;\n                display: grid;\n                grid-template-columns: 1fr 1fr;\n                gap: 8px;\n            }\n\n            #status-text {\n                bottom: 8px;\n                left: 8px;\n                max-width: 64vw;\n                font-size: 12px;\n                padding: 8px 12px;\n            }\n\n            #coords-toggle,\n            #pan-toggle,\n            #lang-btn-en,\n            #lang-btn-jp,\n            #lang-btn-cn {\n                font-size: 12px !important;\n                padding: 6px 8px !important;\n            }\n\n            body.drawer-open {\n                overflow: hidden !important;\n                position: fixed;\n                width: 100%;\n                touch-action: none;\n            }\n\n            #asset-drawer {\n                width: 92vw;\n                max-width: 92vw;\n                height: 100vh;\n                height: 100dvh;\n            }\n            #asset-drawer-body {\n                padding: 8px;\n                -webkit-overflow-scrolling: touch;\n                overscroll-behavior: contain;\n            }\n            #asset-list {\n                display: flex;\n                flex-direction: column;\n                gap: 6px;\n            }\n            .asset-item {\n                grid-template-columns: 52px 1fr 36px;\n                padding: 6px;\n                gap: 6px;\n            }\n            .asset-thumb { width:52px; height:52px; }\n            .asset-path { font-size: 11px; line-height: 1.3; }\n            .asset-sub { font-size: 10px; }\n            #asset-upload-panel {\n                left: 8px;\n                right: 8px;\n                width: auto;\n                max-width: none;\n                bottom: 8px;\n                padding: 8px;\n            }\n            .asset-upload-grid {\n                gap: 6px;\n            }\n            #asset-upload-panel input {\n                min-width: 0;\n                flex: 1 1 42%;\n            }\n            #asset-upload-panel button {\n                min-height: 36px;\n            }\n        }\n    </style>\n</head>\n<body>\n    <!-- 加载遮罩 -->\n    <div id=\"loading-overlay\">\n        <div id=\"loading-text\">Loading Star’s pixel office...</div>\n        <div id=\"loading-progress-container\">\n            <div id=\"loading-progress-bar\"></div>\n        </div>\n    </div>\n    \n    <div id=\"main-stage\">\n        <div id=\"game-container\">\n            <div id=\"game-skeleton\"><div class=\"hint\">正在进入像素办公室…</div></div>\n            <div id=\"status-text\">加载中...</div>\n        </div>\n    \n    <!-- 底部面板容器 -->\n    <div id=\"bottom-panels\">\n        <!-- Memo 面板 -->\n        <div id=\"memo-panel\">\n            <div id=\"memo-title\">昨 日 小 记</div>\n            <div id=\"memo-date\"></div>\n            <div class=\"memo-decoration\">─ ─ ─ ─ ─</div>\n            <div id=\"memo-content\">\n                <div id=\"memo-placeholder\">加载中...</div>\n            </div>\n            <div class=\"memo-decoration\">─ ─ ─ ─ ─</div>\n        </div>\n        \n        <!-- 状态控制栏 -->\n        <div id=\"control-bar\">\n            <div id=\"control-bar-title\">Star 状态</div>\n            <div id=\"control-buttons\">\n                <button id=\"btn-state-idle\" onclick=\"setState('idle','待命')\">待命</button>\n                <button id=\"btn-state-writing\" onclick=\"setState('writing','工作中')\">工作</button>\n                <button id=\"btn-state-syncing\" onclick=\"setState('syncing','同步中')\">同步</button>\n                <button id=\"btn-state-error\" onclick=\"setState('error','报警中')\">报警</button>\n                <button id=\"btn-open-drawer\" onclick=\"toggleAssetDrawer()\">装修房间</button>\n            </div>\n        </div>\n\n        <!-- Guest Agent 名单面板（右下角） -->\n        <div id=\"guest-agent-panel\">\n            <div id=\"guest-agent-panel-title\">访 客 列 表</div>\n            <div id=\"guest-agent-list\">\n                <div style=\"color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;\">正在加载访客...</div>\n            </div>\n            </div>\n        </div>\n    </div>\n    \n    <div id=\"asset-highlight\"></div>\n    <div id=\"room-loading-overlay\" aria-live=\"polite\" aria-busy=\"true\">\n        <div class=\"room-loading-inner\">\n            <div id=\"room-loading-emoji\">🦞</div>\n            <div id=\"room-loading-text\">正在打包虾头……</div>\n        </div>\n    </div>\n\n    <div id=\"asset-drawer-backdrop\" onclick=\"toggleAssetDrawer(false)\"></div>\n    <aside id=\"asset-drawer\">\n        <div id=\"asset-drawer-header\">\n            <span>装修房间 · 资产侧边栏</span>\n            <button id=\"btn-close-drawer\" onclick=\"toggleAssetDrawer(false)\">关闭</button>\n        </div>\n        <div id=\"asset-drawer-body\">\n            <div id=\"asset-auth-gate\" class=\"asset-preview-box\">\n                <div class=\"asset-preview-title\">请输入装修验证码</div>\n                <div class=\"asset-toolbar\">\n                    <input id=\"asset-pass-input\" type=\"password\" placeholder=\"输入验证码\" />\n                    <button onclick=\"unlockAssetDrawer()\">验证</button>\n                </div>\n                <div id=\"asset-auth-msg\" class=\"asset-sub\"></div>\n            </div>\n\n            <div id=\"asset-main-content\" style=\"display:none;\">\n                <div id=\"asset-move-panel\">\n                    <div class=\"asset-toolbar\" id=\"asset-move-row\">\n                        <button id=\"btn-move-house\" class=\"btn-move\" onclick=\"generateRpgBackground()\">📦 搬新家</button>\n                        <button id=\"btn-back-home\" class=\"btn-home\" onclick=\"restoreHomeBackground()\">🐚 回老家</button>\n                    </div>\n                    <div class=\"asset-toolbar\" id=\"asset-broker-row\">\n                        <button class=\"btn-broker\" onclick=\"toggleBrokerPanel()\">🤝 找中介</button>\n                        <button id=\"btn-diy\" class=\"btn-diy\" onclick=\"toggleManualPanel()\">🪚 自己装</button>\n                    </div>\n                    <div id=\"asset-move-result\" class=\"asset-sub\" style=\"margin-top:4px; margin-bottom:6px;\"></div>\n                    <div id=\"asset-broker-panel\">\n                        <div class=\"asset-sub\" style=\"margin-bottom:6px;\">写你的风格主题（严格保持原始房间结构，只改变视觉风格）</div>\n                        <textarea id=\"asset-broker-prompt\" placeholder=\"例如：像素风赛博东京夜景，霓虹灯、雨夜地面反光、蓝紫主色\"></textarea>\n                        <div class=\"asset-toolbar\" style=\"margin-top:6px; gap:8px; align-items:center; justify-content:flex-start;\">\n                            <span id=\"speed-mode-label\" class=\"asset-sub\" style=\"min-width:62px;\">生成模式</span>\n                            <button id=\"speed-fast-btn\" type=\"button\" onclick=\"setSpeedMode('fast')\" style=\"background:#22c55e;color:#052e16;border-color:#16a34a;\">🍌2</button>\n                            <button id=\"speed-quality-btn\" type=\"button\" onclick=\"setSpeedMode('quality')\" style=\"background:#334155;color:#e5e7eb;border-color:#475569;\">🍌Pro</button>\n                        </div>\n                        <details id=\"asset-gemini-panel\" style=\"margin-top:6px; border:0; border-top:1px solid rgba(148,163,184,.18); border-radius:0; padding:8px 0 0; background:transparent;\">\n                            <summary id=\"gemini-panel-summary\" style=\"cursor:pointer; color:#cbd5e1;\">🔐 API 设置（可折叠）</summary>\n                            <div id=\"asset-gemini-config\" style=\"display:block; margin-top:6px;\">\n                                <div id=\"gemini-config-hint\" class=\"asset-sub\" style=\"margin-bottom:4px;\">可选：填写你的生图 API Key（留空不影响基础功能）</div>\n                                <div class=\"asset-sub\" style=\"margin-bottom:6px;\"><a id=\"gemini-api-doc-link\" href=\"https://ai.google.dev/gemini-api/docs/api-key?hl=zh-cn\" target=\"_blank\" rel=\"noopener noreferrer\">📘 如何申请 Google API Key</a></div>\n                                <div id=\"gemini-mask-status\" class=\"asset-sub\" style=\"margin-bottom:6px; color:#a7f3d0;\"></div>\n                                <div class=\"asset-toolbar\" style=\"gap:6px; flex-wrap:wrap;\">\n                                    <input id=\"gemini-api-key-input\" type=\"password\" placeholder=\"粘贴 GEMINI_API_KEY（不会回显）\" style=\"min-width:220px; flex:1;\" autocomplete=\"new-password\" />\n                                    <button id=\"btn-save-gemini-key\" onclick=\"saveGeminiConfigFromUI()\">保存 Key</button>\n                                </div>\n                                <div id=\"gemini-config-msg\" class=\"asset-sub\" style=\"margin-top:4px;\"></div>\n                            </div>\n                        </details>\n                        <div id=\"asset-broker-actions\">\n                            <button onclick=\"generateCustomRpgBackground()\">按中介方案搬家</button>\n                        </div>\n                    </div>\n                </div>\n\n                <div id=\"asset-home-actions-panel\" class=\"asset-preview-box\" style=\"margin-bottom:10px;\">\n                    <div class=\"asset-toolbar\" style=\"margin-bottom:6px; gap:8px;\">\n                        <button id=\"btn-back-last-bg\" class=\"btn-home\" onclick=\"restoreLastGeneratedBackground()\">↩️ 回上一个家</button>\n                        <button id=\"btn-favorite-home\" class=\"btn-home\" onclick=\"saveCurrentHomeFavorite()\">⭐ 收藏这个家</button>\n                    </div>\n                    <details id=\"asset-home-favorites\" style=\"margin:0;\">\n                        <summary id=\"asset-home-favorites-title\" class=\"asset-preview-title\">🏠 收藏的家</summary>\n                        <div id=\"asset-home-favorites-list\" class=\"home-fav-list\"></div>\n                    </details>\n                </div>\n\n                <div id=\"asset-manual-panel\" style=\"display:flex; flex-direction:column; flex:1; min-height:0;\">\n                    <div class=\"asset-toolbar\">\n                        <input id=\"asset-search\" placeholder=\"搜索资产名（如 desk / sofa / star）\" oninput=\"renderAssetDrawerList()\" />\n                    </div>\n                    <div id=\"asset-list\"></div>\n                    <div id=\"asset-upload-panel\">\n                        <input id=\"asset-upload-file\" type=\"file\" accept=\"image/*\" style=\"display:none;\" />\n                        <div class=\"asset-upload-grid\" style=\"margin-top:0; margin-bottom:6px; gap:8px;\">\n                            <button id=\"asset-choose-btn\" onclick=\"openInlineAssetUploader()\">上传素材</button>\n                            <button id=\"asset-commit-refresh-btn\" onclick=\"commitAndRefresh()\" disabled style=\"opacity:.55;\">确认刷新</button>\n                            <button id=\"asset-reset-default-btn\" onclick=\"resetSelectedAssetToDefault()\" disabled style=\"opacity:.55;\">重置默认</button>\n                            <button id=\"asset-restore-prev-btn\" onclick=\"restoreSelectedAssetPrev()\" disabled style=\"opacity:.55;\">恢复上版</button>\n                        </div>\n                        <div id=\"asset-upload-result\" class=\"asset-sub\"></div>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </aside>\n    <div id=\"coords-overlay\" style=\"display:none; position:fixed; pointer-events:none; background:rgba(0,0,0,0.85); color:#fff; font-family:ArkPixel,monospace; font-size:14px; padding:8px 12px; border-radius:4px; z-index:99999;\">\n        <div id=\"coords-display\">X: 0 | Y: 0</div>\n    </div>\n    <button id=\"coords-toggle\" style=\"position:fixed; top:calc(env(safe-area-inset-top, 0px) + 12px); right:12px; z-index:999999; padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;\">\n        显示坐标\n    </button>\n    <button id=\"pan-toggle\" style=\"position:fixed; top:calc(env(safe-area-inset-top, 0px) + 12px); left:12px; z-index:999999; padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;\">\n        移动视野\n    </button>\n    <div id=\"lang-toggle-group\" style=\"position:fixed; top:calc(env(safe-area-inset-top, 0px) + 52px); left:12px; z-index:1000002; display:flex; gap:2px; align-items:center;\">\n        <button id=\"lang-btn-en\" onclick=\"setUILanguage('en')\" style=\"padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;\">EN</button>\n        <button id=\"lang-btn-jp\" onclick=\"setUILanguage('ja')\" style=\"padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;\">JP</button>\n        <button id=\"lang-btn-cn\" onclick=\"setUILanguage('zh')\" style=\"padding:8px 10px; font-family:ArkPixel,monospace; font-size:13px; cursor:pointer; border:2px solid #333; border-radius:5px; background:#333; color:#fff;\">CN</button>\n    </div>\n\n    <script src=\"/static/vendor/phaser-3.80.1.min.js?v={{VERSION_TIMESTAMP}}\"></script>\n    <script>\n        // 简易中英文切换\n        let uiLang = localStorage.getItem('uiLang') || 'en';\n        const I18N = {\n            zh: {\n                controlTitle: 'Star 状态',\n                btnIdle: '待命', btnWork: '工作', btnSync: '同步', btnError: '报警', btnDecor: '装修房间',\n                drawerTitle: '装修房间 · 资产侧边栏', drawerClose: '关闭',\n                authTitle: '请输入装修验证码', authPlaceholder: '输入验证码', authVerify: '验证', authDefaultPassHint: '默认密码：1234（可随时让我帮你改，建议改成强密码）',\n                drawerVisibilityTip: '可见性：点击条目右侧眼睛按钮切换该资产显示',\n                hideDrawer: '👁 隐藏侧边栏', showDrawer: '👁 显示侧边栏',\n                assetHide: '隐藏', assetShow: '显示',\n                resetToDefault: '重置为默认资产', restorePrevAsset: '用上一版',\n                btnMove: '📦 搬新家', btnHome: '🐚 回老家', btnHomeLast: '↩️ 回上一个家', btnHomeFavorite: '⭐ 收藏这个家', btnBroker: '🤝 找中介', btnDIY: '🪚 自己装', btnBrokerGo: '听中介的',\n                homeFavTitle: '🏠 收藏的家', homeFavEmpty: '还没有收藏，先点“⭐ 收藏这个家”', homeFavApply: '替换到当前地图', homeFavDelete: '删除', homeFavSaved: '✅ 已收藏当前地图', homeFavApplied: '✅ 已替换为收藏地图', homeFavDeleted: '🗑️ 已删除收藏',\n                brokerHint: '你会给龙虾推荐什么样的房子',\n                brokerPromptPh: '例如：故宫主题、莫奈风格、地牢主题、兵马俑主题……',\n                brokerNeedPrompt: '请先输入中介方案描述',\n                brokerGenerating: '🏘️ 正在按中介方案生成底图，请稍候（约20-90秒）...',\n                brokerDone: '✅ 已按中介方案生成并替换底图，正在刷新房间...',\n                moveSuccess: '✅ 搬家成功！',\n                brokerMissingKey: '❌ 生图失败：缺少 GEMINI API Key，请在下方填写并保存后重试',\n                geminiPanelTitle: '🔐 API 设置（可折叠）', geminiHint: '可选：填写你的生图 API Key（留空不影响基础功能）', geminiApiDoc: '📘 如何申请 Google API Key', geminiInputPh: '粘贴 GEMINI_API_KEY（不会回显）', geminiSaveKey: '保存 Key', geminiMaskNoKey: '当前状态：未配置 Key', geminiMaskHasKey: '当前已配置：',\n                speedModeLabel: '生成模式', speedFast: '🍌2', speedQuality: '🍌Pro',\n                searchPlaceholder: '搜索资产名（如 desk / sofa / star）', loaded: '已加载', allAssets: '全部资产',\n                chooseImage: '上传素材', confirmUpload: '确认刷新', resetToDefault: '重置默认', restorePrevAsset: '恢复上版', uploadPending: '待上传', uploadTarget: '目标',\n                assetHintNotInScene: '当前场景未检测到此对象，仍可替换文件（刷新后生效）',\n                assetHintDefault: '通用素材：建议保持原图尺寸、透明通道与视觉重心一致，避免错位或失真',\n                showCoords: '显示坐标', hideCoords: '隐藏坐标', moveView: '移动视野', lockView: '锁定视野',\n                memoTitle: '昨 日 小 记', guestTitle: '访 客 列 表', officeTitle: '海辛小龙虾的办公室',\n                loadingOffice: '正在加载 Star 的像素办公室...'\n            },\n            en: {\n                controlTitle: 'Star Status',\n                btnIdle: 'Idle', btnWork: 'Work', btnSync: 'Sync', btnError: 'Alert', btnDecor: 'Decorate Room',\n                drawerTitle: 'Decorate Room · Asset Sidebar', drawerClose: 'Close',\n                authTitle: 'Enter Decor Passcode', authPlaceholder: 'Enter passcode', authVerify: 'Verify', authDefaultPassHint: 'Default passcode: 1234 (ask me anytime to change it; stronger passcode recommended)',\n                drawerVisibilityTip: 'Visibility: use the eye button on each row to hide/show that asset',\n                hideDrawer: '👁 Hide Drawer', showDrawer: '👁 Show Drawer',\n                assetHide: 'Hide', assetShow: 'Show',\n                resetToDefault: 'Reset to Default', restorePrevAsset: 'Use Previous',\n                btnMove: '📦 New Home', btnHome: '🐚 Go Home', btnHomeLast: '↩️ Last One', btnHomeFavorite: '⭐ Save This Home', btnBroker: '🤝 Broker', btnDIY: '🪚 DIY', btnBrokerGo: 'Follow Broker',\n                homeFavTitle: '🏠 Saved Homes', homeFavEmpty: 'No saved homes yet. Tap “⭐ Save This Home” first.', homeFavApply: 'Apply to Current Map', homeFavDelete: 'Delete', homeFavSaved: '✅ Current map saved', homeFavApplied: '✅ Applied saved home', homeFavDeleted: '🗑️ Saved home deleted',\n                brokerHint: 'What kind of house would you recommend for Lobster?',\n                brokerPromptPh: 'e.g. Forbidden City theme, Monet style, dungeon theme, Terracotta Warriors theme...',\n                brokerNeedPrompt: 'Please enter broker style prompt first',\n                brokerGenerating: '🏘️ Generating room background from broker plan, please wait (20-90s)...',\n                brokerDone: '✅ Broker plan applied and background replaced, refreshing room...',\n                moveSuccess: '✅ Move successful!',\n                brokerMissingKey: '❌ Generation failed: missing GEMINI API key. Fill it below and retry.',\n                geminiPanelTitle: '🔐 API Settings (collapsible)', geminiHint: 'Optional: set your image API key (base features work without it)', geminiApiDoc: '📘 How to get a Google API Key', geminiInputPh: 'Paste GEMINI_API_KEY (input hidden)', geminiSaveKey: 'Save Key', geminiMaskNoKey: 'Current: no key configured', geminiMaskHasKey: 'Configured key:',\n                speedModeLabel: 'Render Mode', speedFast: '🍌2', speedQuality: '🍌Pro',\n                searchPlaceholder: 'Search assets (desk / sofa / star)', loaded: 'Loaded', allAssets: 'All Assets',\n                chooseImage: 'Upload Asset', confirmUpload: 'Apply Refresh', resetToDefault: 'Reset Default', restorePrevAsset: 'Restore Prev', uploadPending: 'Pending Upload', uploadTarget: 'Target',\n                assetHintNotInScene: 'This object is not detected in current scene; you can still replace file (effective after refresh)',\n                assetHintDefault: 'Generic asset: keep source size, alpha channel, and visual anchor to avoid drift/distortion',\n                showCoords: 'Show Coords', hideCoords: 'Hide Coords', moveView: 'Pan View', lockView: 'Lock View',\n                memoTitle: 'YESTERDAY NOTES', guestTitle: 'VISITOR LIST', officeTitle: 'Haixin Lobster Office',\n                loadingOffice: 'Loading Star’s pixel office...'\n            },\n            ja: {\n                controlTitle: 'Star ステータス',\n                btnIdle: '待機', btnWork: '作業', btnSync: '同期', btnError: '警報', btnDecor: '部屋を編集',\n                drawerTitle: '部屋編集・アセットサイドバー', drawerClose: '閉じる',\n                authTitle: '編集パスコードを入力', authPlaceholder: 'パスコード入力', authVerify: '認証', authDefaultPassHint: '初期パスコード：1234（いつでも変更を相談可。強固なパス推奨）',\n                drawerVisibilityTip: '表示切替：各行右側の目ボタンで資産を表示/非表示',\n                hideDrawer: '👁 サイドバーを隠す', showDrawer: '👁 サイドバーを表示',\n                assetHide: '非表示', assetShow: '表示',\n                resetToDefault: 'デフォルトへ戻す', restorePrevAsset: '前の版へ戻す',\n                btnMove: '📦 引っ越し', btnHome: '🐚 実家に戻る', btnHomeLast: '↩️ ひとつ前へ', btnHomeFavorite: '⭐ この家を保存', btnBroker: '🤝 仲介', btnDIY: '🪚 自分で装飾', btnBrokerGo: '仲介に任せる',\n                homeFavTitle: '🏠 保存した家', homeFavEmpty: 'まだ保存がありません。先に「⭐ この家を保存」を押してください。', homeFavApply: '現在のマップに適用', homeFavDelete: '削除', homeFavSaved: '✅ 現在のマップを保存しました', homeFavApplied: '✅ 保存した家を適用しました', homeFavDeleted: '🗑️ 保存した家を削除しました',\n                brokerHint: 'ロブスターにはどんな家をおすすめしますか',\n                brokerPromptPh: '例：故宮テーマ、モネ風、ダンジョン風、兵馬俑テーマ…',\n                brokerNeedPrompt: '先に仲介プランの説明を入力してください',\n                brokerGenerating: '🏘️ 仲介プランで背景を生成中（20〜90秒）...',\n                brokerDone: '✅ 仲介プランを適用して背景を更新しました。部屋を更新中...',\n                moveSuccess: '✅ 引っ越し成功！',\n                brokerMissingKey: '❌ 生成失敗：GEMINI APIキーが未設定です。下で入力して保存してください。',\n                geminiPanelTitle: '🔐 API設定（折りたたみ）', geminiHint: '任意：画像生成APIキーを設定（未設定でも基本機能は利用可）', geminiApiDoc: '📘 Google API Keyの取得方法', geminiInputPh: 'GEMINI_API_KEY を貼り付け（入力は非表示）', geminiSaveKey: 'Keyを保存', geminiMaskNoKey: '現在：キー未設定', geminiMaskHasKey: '設定済みキー：',\n                speedModeLabel: '生成モード', speedFast: '🍌2', speedQuality: '🍌Pro',\n                searchPlaceholder: 'アセット検索（desk / sofa / star）', loaded: '読み込み済み', allAssets: '全アセット',\n                chooseImage: '素材アップロード', confirmUpload: '確定して更新', resetToDefault: '初期に戻す', restorePrevAsset: '前版に戻す', uploadPending: 'アップロード待ち', uploadTarget: '対象',\n                assetHintNotInScene: '現在のシーンでこのオブジェクトは未検出です。ファイル差し替えは可能（更新後に反映）',\n                assetHintDefault: '汎用素材：元サイズ・透過・視覚アンカーを維持し、ズレや崩れを防いでください',\n                showCoords: '座標表示', hideCoords: '座標非表示', moveView: '視点移動', lockView: '視点固定',\n                memoTitle: '昨日のメモ', guestTitle: '訪問者リスト', officeTitle: 'ハイシン・ロブスターのオフィス',\n                loadingOffice: 'Star のピクセルオフィスを読み込み中...'\n            }\n        };\n\n        function t(key) { return (I18N[uiLang] && I18N[uiLang][key]) || key; }\n        function renderBootLoadingText(percent) {\n            const loadingEl = document.getElementById('loading-text');\n            if (!loadingEl) return;\n            const base = t('loadingOffice');\n            const p = Number.isFinite(percent) ? ` ${Math.max(0, Math.min(100, Math.round(percent)))}%` : '';\n            loadingEl.textContent = `${base}${p}`;\n        }\n\n        function ensureMemoBgVisible() {\n            const panel = document.getElementById('memo-panel');\n            if (!panel) return;\n            panel.style.backgroundImage = \"url('/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}')\";\n            panel.classList.remove('no-bg');\n        }\n\n        function applyLanguage() {\n            const setText = (id, key) => { const el = document.getElementById(id); if (el) el.textContent = t(key); };\n            const setPh = (id, key) => { const el = document.getElementById(id); if (el) el.placeholder = t(key); };\n\n            setText('control-bar-title', 'controlTitle');\n            setText('btn-state-idle', 'btnIdle');\n            setText('btn-state-writing', 'btnWork');\n            setText('btn-state-syncing', 'btnSync');\n            setText('btn-state-error', 'btnError');\n            setText('btn-open-drawer', 'btnDecor');\n            const langButtons = [\n                { id: 'lang-btn-en', lang: 'en' },\n                { id: 'lang-btn-jp', lang: 'ja' },\n                { id: 'lang-btn-cn', lang: 'zh' }\n            ];\n            langButtons.forEach(({ id, lang }) => {\n                const el = document.getElementById(id);\n                if (!el) return;\n                const active = (uiLang === lang);\n                el.style.background = active ? '#22c55e' : '#333';\n                el.style.borderColor = active ? '#22c55e' : '#333';\n                el.style.color = '#fff';\n            });\n\n            const drawerTitle = document.querySelector('#asset-drawer-header span');\n            if (drawerTitle) drawerTitle.textContent = t('drawerTitle');\n            const drawerClose = document.getElementById('btn-close-drawer');\n            if (drawerClose) drawerClose.textContent = t('drawerClose');\n\n            const authTitle = document.querySelector('#asset-auth-gate .asset-preview-title');\n            if (authTitle) authTitle.textContent = t('authTitle');\n            setPh('asset-pass-input', 'authPlaceholder');\n            const authVerifyBtn = document.querySelector('#asset-auth-gate .asset-toolbar button');\n            if (authVerifyBtn) authVerifyBtn.textContent = t('authVerify');\n\n            setText('btn-move-house', 'btnMove');\n            setText('btn-back-home', 'btnHome');\n            const brokerBtn = document.querySelector('#asset-broker-row .btn-broker'); if (brokerBtn) brokerBtn.textContent = t('btnBroker');\n            const diyBtn = document.querySelector('#asset-broker-row .btn-diy'); if (diyBtn) diyBtn.textContent = t('btnDIY');\n            const backLastBtn = document.getElementById('btn-back-last-bg'); if (backLastBtn) backLastBtn.textContent = t('btnHomeLast');\n            const favHomeBtn = document.getElementById('btn-favorite-home'); if (favHomeBtn) favHomeBtn.textContent = t('btnHomeFavorite');\n            const favTitle = document.getElementById('asset-home-favorites-title'); if (favTitle) favTitle.textContent = t('homeFavTitle');\n            const brokerHint = document.querySelector('#asset-broker-panel .asset-sub'); if (brokerHint) brokerHint.textContent = t('brokerHint');\n            const brokerPrompt = document.getElementById('asset-broker-prompt'); if (brokerPrompt) brokerPrompt.placeholder = t('brokerPromptPh');\n            const brokerGoBtn = document.querySelector('#asset-broker-actions button'); if (brokerGoBtn) brokerGoBtn.textContent = t('btnBrokerGo');\n            const speedLbl = document.getElementById('speed-mode-label'); if (speedLbl) speedLbl.textContent = t('speedModeLabel');\n            const speedFastBtn = document.getElementById('speed-fast-btn'); if (speedFastBtn) speedFastBtn.textContent = t('speedFast');\n            const speedQualityBtn = document.getElementById('speed-quality-btn'); if (speedQualityBtn) speedQualityBtn.textContent = t('speedQuality');\n            const geminiPanelSummary = document.getElementById('gemini-panel-summary'); if (geminiPanelSummary) geminiPanelSummary.textContent = t('geminiPanelTitle');\n            const geminiHint = document.getElementById('gemini-config-hint'); if (geminiHint) geminiHint.textContent = t('geminiHint');\n            const geminiDocLink = document.getElementById('gemini-api-doc-link'); if (geminiDocLink) geminiDocLink.textContent = t('geminiApiDoc');\n            const geminiInput = document.getElementById('gemini-api-key-input'); if (geminiInput) geminiInput.placeholder = t('geminiInputPh');\n            const geminiSaveBtn = document.getElementById('btn-save-gemini-key'); if (geminiSaveBtn) geminiSaveBtn.textContent = t('geminiSaveKey');\n\n            setPh('asset-search', 'searchPlaceholder');\n\n            setText('asset-choose-btn', 'chooseImage');\n            setText('asset-commit-refresh-btn', 'confirmUpload');\n            setText('asset-reset-default-btn', 'resetToDefault');\n            setText('asset-restore-prev-btn', 'restorePrevAsset');\n\n            const memoTitle = document.getElementById('memo-title');\n            if (memoTitle) memoTitle.textContent = t('memoTitle');\n            const guestTitle = document.getElementById('guest-agent-panel-title');\n            if (guestTitle) guestTitle.textContent = t('guestTitle');\n            const plaqueTitle = (typeof window.officeNameFromServer !== 'undefined' && window.officeNameFromServer) || t('officeTitle');\n            if (window.officePlaqueText && window.officePlaqueText.setText) {\n                window.officePlaqueText.setText(plaqueTitle);\n            }\n\n            const coordsBtn = document.getElementById('coords-toggle');\n            if (coordsBtn) coordsBtn.textContent = showCoords ? t('hideCoords') : t('showCoords');\n            const panBtn = document.getElementById('pan-toggle');\n            if (panBtn) {\n                const on = panBtn.dataset.on === '1';\n                panBtn.textContent = on ? t('lockView') : t('moveView');\n            }\n            ensureMemoBgVisible();\n            renderBootLoadingText(Number(loadingProgressBar?.style?.width?.replace('%','') || 0));\n        }\n\n        function setUILanguage(lang) {\n            if (!['zh', 'en', 'ja'].includes(lang)) return;\n            uiLang = lang;\n            localStorage.setItem('uiLang', uiLang);\n            applyLanguage();\n            updateSpeedModeUI();\n\n            // 语言切换后立即重绘资产侧栏，确保易懂名同步更新\n            renderAssetDrawerList();\n\n            // 语言切换后同步刷新已选资产的指导文案（上传区小字三语联动）\n            if (selectedAssetInfo && selectedAssetInfo.path) {\n                const inScene = !!mapAssetPathToSprite(selectedAssetInfo.path);\n                renderSelectedAssetGuidance(selectedAssetInfo.path, inScene);\n            }\n\n            // 语言切换时，当前正在显示的 loading 文案也实时切换\n            const overlay = document.getElementById('room-loading-overlay');\n            if (overlay && overlay.style.display === 'flex') {\n                showRoomLoadingOverlay();\n            }\n        }\n\n        // 检测浏览器是否支持 WebP\n        let supportsWebP = false;\n        \n        // 方法 1: 使用 canvas 检测\n        function checkWebPSupport() {\n            return new Promise((resolve) => {\n                const canvas = document.createElement('canvas');\n                if (canvas.getContext && canvas.getContext('2d')) {\n                    resolve(canvas.toDataURL('image/webp').indexOf('data:image/webp') === 0);\n                } else {\n                    resolve(false);\n                }\n            });\n        }\n        \n        // 方法 2: 使用 image 检测（备用）\n        function checkWebPSupportFallback() {\n            return new Promise((resolve) => {\n                const img = new Image();\n                img.onload = () => resolve(true);\n                img.onerror = () => resolve(false);\n                img.src = 'data:image/webp;base64,UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAABBxAR/Q9ERP8DAABWUDggGAAAADABAJ0BKgEAAQADADQlpAADcAD++/1QAA==';\n            });\n        }\n\n        const IS_TOUCH_DEVICE = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.matchMedia('(pointer: coarse)').matches;\n\n        const config = {\n            type: Phaser.AUTO,\n            width: 1280,\n            height: 720,\n            parent: 'game-container',\n            pixelArt: true,\n            // 桌面端保持 FIT；手机端用 RESIZE，并在相机里按高度做 fit（可横向 pan）\n            scale: {\n                mode: IS_TOUCH_DEVICE ? Phaser.Scale.RESIZE : Phaser.Scale.FIT,\n                autoCenter: Phaser.Scale.CENTER_BOTH,\n                width: 1280,\n                height: 720\n            },\n            physics: { default: 'arcade', arcade: { gravity: { y: 0 }, debug: false } },\n            scene: { preload: preload, create: create, update: update }\n        };\n\n        let totalAssets = 0;\n        let loadedAssets = 0;\n        let loadingProgressBar, loadingProgressContainer, loadingOverlay, loadingText;\n\n        // Memo 相关函数\n        async function loadMemo() {\n            const memoDate = document.getElementById('memo-date');\n            const memoContent = document.getElementById('memo-content');\n            \n            try {\n                const response = await fetch('/yesterday-memo?t=' + Date.now(), { cache: 'no-store' });\n                const data = await response.json();\n                \n                if (data.success && data.memo) {\n                    memoDate.textContent = data.date || '';\n                    memoContent.innerHTML = data.memo.replace(/\\n/g, '<br>');\n                } else {\n                    memoContent.innerHTML = '<div id=\"memo-placeholder\">暂无昨日日记</div>';\n                }\n            } catch (e) {\n                console.error('加载 memo 失败:', e);\n                memoContent.innerHTML = '<div id=\"memo-placeholder\">加载失败</div>';\n            }\n        }\n\n        // 更新加载进度\n        function updateLoadingProgress() {\n            loadedAssets++;\n            const percent = Math.min(100, Math.round((loadedAssets / totalAssets) * 100));\n            if (loadingProgressBar) {\n                loadingProgressBar.style.width = percent + '%';\n            }\n            if (loadingText) {\n                renderBootLoadingText(percent);\n            }\n        }\n\n        // 隐藏加载界面\n        function hideLoadingOverlay() {\n            setTimeout(() => {\n                if (loadingOverlay) {\n                    loadingOverlay.style.transition = 'opacity 0.35s ease';\n                    loadingOverlay.style.opacity = '0';\n                    setTimeout(() => {\n                        loadingOverlay.style.display = 'none';\n                    }, 360);\n                }\n            }, 80);\n        }\n\n        // Phaser 就绪后，移除首屏骨架，避免黑屏感\n        function hideGameSkeleton() {\n            const sk = document.getElementById('game-skeleton');\n            if (!sk) return;\n            sk.style.transition = 'opacity 0.25s ease';\n            sk.style.opacity = '0';\n            setTimeout(() => {\n                if (sk && sk.parentNode) sk.parentNode.removeChild(sk);\n            }, 260);\n        }\n\n        // 兜底：某些移动网络/CDN 抖动时，避免一直卡在“加载中”遮罩\n        setTimeout(() => {\n            if (loadingOverlay && loadingOverlay.style.display !== 'none') {\n                hideLoadingOverlay();\n            }\n        }, 8000);\n        \n        // 懒加载逻辑已取消（体验优先：装饰首屏直接出现）\n\n        const STATES = {\n            idle: { name: '待命', area: 'breakroom' },\n            writing: { name: '整理文档', area: 'writing' },\n            researching: { name: '搜索信息', area: 'researching' },\n            executing: { name: '执行任务', area: 'writing' },\n            syncing: { name: '同步备份', area: 'writing' },\n            error: { name: '出错了', area: 'error' }\n        };\n\n        const BUBBLE_TEXTS = {\n            zh: {\n                idle: ['待命中：耳朵竖起来了','我在这儿，随时可以开工','先把桌面收拾干净再说','呼——给大脑放个风','今天也要优雅地高效','等待，是为了更准确的一击','咖啡还热，灵感也还在','我在后台给你加 Buff','状态：静心 / 充电','小猫说：慢一点也没关系'],\n                writing: ['进入专注模式：勿扰','先把关键路径跑通','我来把复杂变简单','把 bug 关进笼子里','写到一半，先保存','把每一步都做成可回滚','今天的进度，明天的底气','先收敛，再发散','让系统变得更可解释','稳住，我们能赢'],\n                researching: ['我在挖证据链','让我把信息熬成结论','找到了：关键在这里','先把变量控制住','我在查：它为什么会这样','把直觉写成验证','先定位，再优化','别急，先画因果图'],\n                executing: ['执行中：不要眨眼','把任务切成小块逐个击破','开始跑 pipeline','一键推进：走你','让结果自己说话','先做最小可行，再做最美版本'],\n                syncing: ['同步中：把今天锁进云里','备份不是仪式，是安全感','写入中…别断电','把变更交给时间戳','云端对齐：咔哒','同步完成前先别乱动','把未来的自己从灾难里救出来','多一份备份，少一份后悔'],\n                error: ['警报响了：先别慌','我闻到 bug 的味道了','先复现，再谈修复','把日志给我，我会说人话','错误不是敌人，是线索','把影响面圈起来','先止血，再手术','我在：马上定位根因','别怕，这种我见多了','报警中：让问题自己现形'],\n                cat: ['喵~','咕噜咕噜…','尾巴摇一摇','晒太阳最开心','有人来看我啦','我是这个办公室的吉祥物','伸个懒腰','今天的罐罐准备好了吗','呼噜呼噜','这个位置视野最好']\n            },\n            en: {\n                idle: ['On standby: ears up.','I’m here, ready to roll.','Let’s tidy the desk first.','Taking a quick brain breeze.','Efficient and elegant, as always.','Waiting for a more precise strike.','Coffee is warm, ideas too.','Giving you a quiet backstage buff.','Status: calm / charging.','Cat says: no rush, we’re good.'],\n                writing: ['Focus mode on: do not disturb.','Let’s clear the critical path first.','I’ll make the complex simple.','Putting bugs in a cage.','Save first, then continue.','Every step should be rollback-safe.','Today’s progress is tomorrow’s confidence.','Converge first, then diverge.','Making the system more explainable.','Steady—this is winnable.'],\n                researching: ['Digging the evidence chain.','Let me boil info into conclusions.','Found it: key clue here.','Control variables first.','Checking why this happens.','Turn intuition into verification.','Locate first, optimize next.','No rush—draw the causality map first.'],\n                executing: ['Executing—don’t blink.','Split tasks, conquer one by one.','Pipeline is running.','One-click push: go go.','Let the results speak.','Build MVP first, then craft beauty.'],\n                syncing: ['Syncing: lock today into the cloud.','Backup is safety, not ceremony.','Writing… don’t cut power.','Handing changes to timestamps.','Cloud alignment: click.','Don’t shake it before sync finishes.','Saving future-us from disasters.','One more backup, one less regret.'],\n                error: ['Alarm on—stay calm.','I can smell a bug.','Reproduce first, then fix.','Give me logs; I’ll translate.','Errors are clues, not enemies.','Circle the impact area first.','Stop the bleeding, then surgery.','On it: tracing root cause now.','Don’t worry, seen this many times.','Alert mode: make the issue reveal itself.'],\n                cat: ['Meow~','Purr purr…','Tail wiggle activated.','Sunbathing is the best.','Someone came to see me!','I’m the office mascot.','Big stretch~','Is today’s snack ready yet?','Rrrrr purr…','Best view spot secured.']\n            },\n            ja: {\n                idle: ['待機中：耳はピン。','ここにいるよ、いつでも開始OK。','まず机を整えよう。','ふー、頭に風を通す。','今日も上品に高効率で。','待つのは、より正確な一撃のため。','コーヒーも発想もまだ温かい。','裏側でそっとバフ中。','状態：静心 / 充電。','猫より：ゆっくりでも大丈夫。'],\n                writing: ['集中モード：お静かに。','まずはクリティカルパスを通す。','複雑をシンプルにする。','バグはケージへ。','途中でもまず保存。','すべてをロールバック可能に。','今日の進捗は明日の自信。','まず収束、次に発散。','システムをより説明可能に。','落ち着いて、勝てる。'],\n                researching: ['証拠チェーンを掘っています。','情報を結論まで煮詰めます。','見つけた：鍵はここ。','まず変数を制御。','なぜこうなるか調査中。','直感を検証へ。','先に特定、次に最適化。','急がず因果マップから。'],\n                executing: ['実行中：まばたき厳禁。','タスクを分割して各個撃破。','パイプライン起動。','ワンクリック前進：いくぞ。','結果に語らせる。','まず最小実用、次に美しさ。'],\n                syncing: ['同期中：今日をクラウドに封印。','バックアップは儀式じゃなく安心。','書き込み中…電源オフ厳禁。','変更はタイムスタンプへ。','クラウド整列：カチッ。','同期完了まで触らないで。','未来の自分を災害から救う。','バックアップ一つ、後悔一つ減る。'],\n                error: ['警報：まず落ち着いて。','バグの気配を感じる。','再現してから修正へ。','ログをください、人語にします。','エラーは敵ではなく手がかり。','まず影響範囲を囲う。','止血してから手術。','今すぐ根因を追跡中。','大丈夫、よくある案件。','警戒モード：問題を可視化する。'],\n                cat: ['ニャー','ゴロゴロ…','しっぽフリフリ。','ひなたぼっこ最高。','見に来てくれた！','このオフィスのマスコットです。','ぐーっと伸び。','今日のおやつ、準備できた？','ゴロゴロ。','ここ、いちばん見晴らしがいい。']\n            }\n        };\n\n        let game, star, sofa, serverroom, officeBgSprite, areas = {}, currentState = 'idle', pendingDesiredState = null, statusText, lastFetch = 0, lastBlink = 0, lastBubble = 0, targetX = 660, targetY = 170, bubble = null, typewriterText = '', typewriterTarget = '', typewriterIndex = 0, lastTypewriter = 0, syncAnimSprite = null, syncAnimPlayable = false, catBubble = null, selectionBoxGraphics = null;\n        const IDLE_SOFA_ANCHOR = { x: 798, y: 272 }; // 统一中心锚点（原 sofa 左上 670,144 的中心）\n        const IDLE_STAR_SCALE = 1.0; // star idle 改为256帧原生显示，不再放大\n        // flowers 精灵表规格：固定单帧 128x128，4x4\n        let FLOWERS_FRAME_W = 65;\n        let FLOWERS_FRAME_H = 65;\n        let FLOWERS_FRAME_COLS = 4;\n        let FLOWERS_FRAME_ROWS = 4;\n        let currentOfficeBgTextureKey = 'office_bg';\n        let assetDrawerOpen = false;\n        let assetDrawerAuthed = false;\n        let assetManualPanelOpen = false;\n        let assetFilterMode = 'all';\n        let assetListData = [];\n        let sceneAssetItems = [];\n        let selectedAssetInfo = null;\n        let hiddenAssetPaths = new Set();\n        let assetThumbTimers = [];\n        let homeFavoritesCache = [];\n        let homeFavoritesLoadedAt = 0;\n\n        // 坐标以服务端为准；清理历史本地缓存，避免把素材挪飞\n        let assetPositionOverrides = {};\n        let roomLoadingTimer = null;\n        let roomLoadingIndex = 0;\n        let roomLoadingEmojiIndex = 0;\n\n        // 默认走更稳的模型档（quality），避免部分通道不支持 fast 模型时报错\n        let speedMode = localStorage.getItem('speedMode') || 'quality';\n        function setSpeedMode(mode) {\n            speedMode = (mode === 'quality') ? 'quality' : 'fast';\n            try { localStorage.setItem('speedMode', speedMode); } catch(e) {}\n            updateSpeedModeUI();\n        }\n        function updateSpeedModeUI() {\n            const fastBtn = document.getElementById('speed-fast-btn');\n            const qBtn = document.getElementById('speed-quality-btn');\n            if (!fastBtn || !qBtn) return;\n            const fastOn = speedMode === 'fast';\n            fastBtn.style.background = fastOn ? '#22c55e' : '#334155';\n            fastBtn.style.color = fastOn ? '#052e16' : '#e5e7eb';\n            fastBtn.style.borderColor = fastOn ? '#16a34a' : '#475569';\n            qBtn.style.background = fastOn ? '#334155' : '#22c55e';\n            qBtn.style.color = fastOn ? '#e5e7eb' : '#052e16';\n            qBtn.style.borderColor = fastOn ? '#475569' : '#16a34a';\n        }\n        try { localStorage.removeItem('assetPositionOverrides'); } catch (e) {}\n        let isMoving = false;\n        let waypoints = []; // list of (x,y) to walk through in order\n        let lastWanderAt = 0;\n\n        let coordsOverlay, coordsDisplay, coordsToggle;\n        let showCoords = false;\n        let guestAgents = [];\n        let guestSprites = {}; // agentId -> {sprite, nameText}\n        let guestBubbles = {}; // agentId -> bubble container\n        const GUEST_AVATARS = ['guest_role_1','guest_role_2','guest_role_3','guest_role_4','guest_role_5','guest_role_6'];\n        let guestTweens = {};  // agentId -> {move, name}\n        let hiddenDemoNames = new Set();\n        const DEMO_MODE = new URLSearchParams(window.location.search).get('demo') === '1';\n        const FETCH_INTERVAL = 1000;\n        const GUEST_AGENTS_FETCH_INTERVAL = 3500;\n        const BLINK_INTERVAL = 2500;\n        const BUBBLE_INTERVAL = 8000;\n        const CAT_BUBBLE_INTERVAL = 18000; // cat bubble much less frequent\n        let lastCatBubble = 0;\n        let lastGuestAgentsFetch = 0;\n        let lastGuestBubbleAt = 0;\n        const TYPEWRITER_DELAY = 50;\n        let lastSeenGuestIds = new Set(); // 用于检测新加入的访客，触发欢迎气泡\n        let guestWelcomeInitialized = false;\n\n        // 状态控制栏函数（用于测试）\n        function setState(state, detail) {\n            fetch('/set_state', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ state, detail })\n            })\n            .then((res) => {\n                if (!res.ok) throw new Error(`set_state failed: ${res.status}`);\n                return fetchStatus();\n            })\n            .catch((e) => {\n                console.error('setState failed', e);\n            });\n        }\n\n        function updateAssetAuthUI() {\n            const gate = document.getElementById('asset-auth-gate');\n            const main = document.getElementById('asset-main-content');\n            if (!gate || !main) return;\n            gate.style.display = assetDrawerAuthed ? 'none' : 'block';\n            main.style.display = assetDrawerAuthed ? 'block' : 'none';\n            updateManualPanelUI();\n        }\n\n        function updateManualPanelUI() {\n            const panel = document.getElementById('asset-manual-panel');\n            if (!panel) return;\n            panel.classList.toggle('open', !!assetManualPanelOpen && !!assetDrawerAuthed);\n        }\n\n        async function unlockAssetDrawer() {\n            const input = document.getElementById('asset-pass-input');\n            const msg = document.getElementById('asset-auth-msg');\n            const val = (input?.value || '').trim();\n            if (!val) {\n                if (msg) msg.textContent = '❌ 请输入验证码';\n                return;\n            }\n            try {\n                const res = await fetch('/assets/auth', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ password: val })\n                });\n                const data = await res.json();\n                if (data && data.ok) {\n                    assetDrawerAuthed = true;\n                    if (msg) msg.textContent = '✅ 验证通过';\n                    updateAssetAuthUI();\n                    await refreshAssetDrawerList();\n                    await renderHomeFavorites(false);\n                    bindDrawerFileMeta();\n                } else {\n                    assetDrawerAuthed = false;\n                    if (msg) msg.textContent = '❌ 验证码错误';\n                }\n            } catch (e) {\n                assetDrawerAuthed = false;\n                if (msg) msg.textContent = `❌ 验证失败：${e}`;\n            }\n        }\n\n        function formatSizeHuman(n) {\n            if (!n) return '0 KB';\n            if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(2) + ' MB';\n            return (n / 1024).toFixed(1) + ' KB';\n        }\n        function toAssetStem(v) {\n            const s = (v || '').toLowerCase();\n            const file = s.split('/').pop() || s;\n            return file.replace(/\\.[^.]+$/, '');\n        }\n\n        function getAssetDisplayName(path) {\n            const stem = toAssetStem(path);\n            const lang = (uiLang || 'zh');\n            const nameMap = {\n                zh: {\n                    'star-idle-v5': '主角·待命状态',\n                    'star-working-spritesheet-grid': '主角·工作状态',\n                    'sync-animation': '主角·同步状态',\n                    'sync-animation-v3-grid': '主角·同步状态',\n                    'error-bug-spritesheet-grid': '主角·报错状态',\n                    'cats-spritesheet': '随机猫猫',\n                    'coffee-machine-v3-grid': '咖啡机',\n                    'coffee-machine-shadow-v1': '咖啡机阴影',\n                    'posters-spritesheet': '随机海报',\n                    'serverroom-spritesheet': '服务器房动画',\n                    'plants-spritesheet': '随机绿植',\n                    'flowers-bloom-v2': '随机花朵',\n                    'office_bg_small': '办公室背景',\n                    'memo-bg': '昨日小记底图',\n                    'desk-v3': '办公桌',\n                    'desk': '办公桌（旧）',\n                    'guest_anim_1': '访客动画 1',\n                    'guest_anim_2': '访客动画 2',\n                    'guest_anim_3': '访客动画 3',\n                    'guest_anim_4': '访客动画 4',\n                    'guest_anim_5': '访客动画 5',\n                    'guest_anim_6': '访客动画 6'\n                },\n                en: {\n                    'star-idle-v5': 'Main · Idle',\n                    'star-working-spritesheet-grid': 'Main · Working',\n                    'sync-animation': 'Main · Syncing',\n                    'sync-animation-v3-grid': 'Main · Syncing',\n                    'error-bug-spritesheet-grid': 'Main · Error',\n                    'cats-spritesheet': 'Random Cats',\n                    'coffee-machine-v3-grid': 'Coffee Machine',\n                    'coffee-machine-shadow-v1': 'Coffee Machine Shadow',\n                    'posters-spritesheet': 'Random Posters',\n                    'serverroom-spritesheet': 'Server Room',\n                    'plants-spritesheet': 'Random Plants',\n                    'flowers-bloom-v2': 'Random Flowers',\n                    'office_bg_small': 'Office Background',\n                    'memo-bg': 'Memo Background',\n                    'desk-v3': 'Desk',\n                    'desk': 'Desk (Old)',\n                    'guest_anim_1': 'Guest Animation 1',\n                    'guest_anim_2': 'Guest Animation 2',\n                    'guest_anim_3': 'Guest Animation 3',\n                    'guest_anim_4': 'Guest Animation 4',\n                    'guest_anim_5': 'Guest Animation 5',\n                    'guest_anim_6': 'Guest Animation 6'\n                },\n                ja: {\n                    'star-idle-v5': 'メイン・待機状態',\n                    'star-working-spritesheet-grid': 'メイン・作業状態',\n                    'sync-animation': 'メイン・同期状態',\n                    'sync-animation-v3-grid': 'メイン・同期状態',\n                    'error-bug-spritesheet-grid': 'メイン・エラー状態',\n                    'cats-spritesheet': 'ランダム猫',\n                    'coffee-machine-v3-grid': 'コーヒーマシン',\n                    'coffee-machine-shadow-v1': 'コーヒーマシン影',\n                    'posters-spritesheet': 'ランダムポスター',\n                    'serverroom-spritesheet': 'サーバールーム',\n                    'plants-spritesheet': 'ランダム植物',\n                    'flowers-bloom-v2': 'ランダム花',\n                    'office_bg_small': 'オフィス背景',\n                    'memo-bg': 'メモ背景',\n                    'desk-v3': 'デスク',\n                    'desk': 'デスク（旧）',\n                    'guest_anim_1': '訪客アニメ 1',\n                    'guest_anim_2': '訪客アニメ 2',\n                    'guest_anim_3': '訪客アニメ 3',\n                    'guest_anim_4': '訪客アニメ 4',\n                    'guest_anim_5': '訪客アニメ 5',\n                    'guest_anim_6': '訪客アニメ 6'\n                }\n            };\n            const langMap = nameMap[lang] || nameMap.zh;\n            return langMap[stem] || stem;\n        }\n\n        const ASSET_HELP_TEXT_MAP = {\n            zh: {\n                'office_bg_small': '主场景底图（当前生效）。建议 1280×720（16:9），保留房间结构与视角，避免角色站位错位。',\n                'office_bg': '历史背景备份。通常不直接生效，建议与 office_bg_small 保持同构图用于回退。',\n                'star-idle-v5': '主角待机动画表。请保持 256×256 分帧与网格布局一致，否则待机动作会错帧。',\n                'star-working-spritesheet-grid': '主角工作动画表（工位状态）。请保持 300×300 分帧，建议人物重心与原图一致。',\n                'sync-animation': '同步状态素材（当前引用）。建议按 256×256 帧规范制作，避免同步状态显示静止或抖动。',\n                'sync-animation-v3-grid': '同步动画表（兼容资源）。保持 256×256 网格可用于替换同步动作细节。',\n                'error-bug-spritesheet-grid': '报错状态动画表。请保持 220×220 分帧，建议高对比度以增强异常提示感。',\n                'desk-v3': '办公桌前景层。影响主角前后遮挡关系，建议保持当前比例与锚点视觉重心。',\n                'desk': '旧版办公桌素材（兼容用）。建议与 desk-v3 保持相近体积与锚点，避免遮挡异常。',\n                'sofa-idle-v3': '沙发静态素材。建议保持 256×256 与透明背景，避免替换后位置漂移。',\n                'sofa-shadow-v1': '沙发阴影层。建议与沙发主体同坐标叠放，增强贴地感。',\n                'memo-bg': '小记面板底图。建议留出文字阅读区域，降低高频纹理，避免信息难读。',\n                'plants-spritesheet': '绿植随机素材。保持 160×160 分帧，可一次替换多个绿植位的观感。',\n                'posters-spritesheet': '海报随机素材。保持 160×160 分帧，建议统一风格避免墙面杂乱。',\n                'cats-spritesheet': '猫咪随机素材。保持 160×160 分帧，建议轮廓清晰、识别度高。',\n                'coffee-machine-v3-grid': '咖啡机静态素材。建议保持 230×230 与当前锚点，避免位置偏移。',\n                'coffee-machine-shadow-v1': '咖啡机阴影层。建议与咖啡机本体同宽对齐，增强贴地感。',\n                'serverroom-spritesheet': '服务器房动画表。保持 180×251 分帧，灯效变化建议节奏均匀不过闪。',\n                'flowers-bloom-v2': '花朵随机素材。保持 128×128 分帧，建议色彩与整体办公室主色协调。',\n                'guest_anim_1': '访客动画序列 1（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_2': '访客动画序列 2（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_3': '访客动画序列 3（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_4': '访客动画序列 4（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_5': '访客动画序列 5（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_anim_6': '访客动画序列 6（32×32 分帧）。建议保持像素风、轮廓清晰，与主角风格统一。',\n                'guest_role_1': '访客静态形象备用图 1。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_2': '访客静态形象备用图 2。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_3': '访客静态形象备用图 3。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_4': '访客静态形象备用图 4。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_5': '访客静态形象备用图 5。建议与对应 guest_anim 角色设定一致，避免切换割裂。',\n                'guest_role_6': '访客静态形象备用图 6。建议与对应 guest_anim 角色设定一致，避免切换割裂。'\n            },\n            en: {\n                'office_bg_small': 'Primary room background (active). Use 1280×720 (16:9), keep room structure/perspective to avoid character misalignment.',\n                'office_bg': 'Legacy backup background. Usually not directly active; keep composition aligned with office_bg_small for rollback.',\n                'star-idle-v5': 'Main idle spritesheet. Keep 256×256 frame size and grid layout, or idle animation will break.',\n                'star-working-spritesheet-grid': 'Main working spritesheet (desk state). Keep 300×300 frames; preserve visual center/anchor.',\n                'sync-animation': 'Sync-state asset (currently referenced). Follow 256×256 frame spec to avoid static/jitter sync visuals.',\n                'sync-animation-v3-grid': 'Sync spritesheet (compat resource). Keep 256×256 grid for sync animation replacement.',\n                'error-bug-spritesheet-grid': 'Error-state spritesheet. Keep 220×220 frames; high contrast helps warning readability.',\n                'desk-v3': 'Desk foreground layer. Controls overlap with character; keep ratio and visual anchor stable.',\n                'desk': 'Legacy desk asset (compatibility). Keep size/anchor close to desk-v3 to avoid overlap issues.',\n                'sofa-idle-v3': 'Static sofa asset. Keep 256×256 and transparent background to prevent position drift.',\n                'sofa-shadow-v1': 'Sofa shadow layer. Keep the exact same coordinates as sofa body for grounded feel.',\n                'memo-bg': 'Memo panel background. Reserve readable text area; avoid dense textures behind text.',\n                'plants-spritesheet': 'Random plant sprites. Keep 160×160 frames; updates several plant spots at once.',\n                'posters-spritesheet': 'Random poster sprites. Keep 160×160 frames; prefer consistent style to avoid wall clutter.',\n                'cats-spritesheet': 'Random cat sprites. Keep 160×160 frames; clear silhouette improves recognition.',\n                'coffee-machine-v3-grid': 'Static coffee machine asset. Keep 230×230 size and anchor to avoid drift.',\n                'coffee-machine-shadow-v1': 'Coffee machine shadow layer. Align width/anchor with the machine body for grounded feel.',\n                'serverroom-spritesheet': 'Server-room animation sheet. Keep 180×251 frames; avoid over-flickering lights.',\n                'flowers-bloom-v2': 'Random flower sprites. Keep 128×128 frames; align palette with overall office mood.',\n                'guest_anim_1': 'Guest animation set 1 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_2': 'Guest animation set 2 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_3': 'Guest animation set 3 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_4': 'Guest animation set 4 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_5': 'Guest animation set 5 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_anim_6': 'Guest animation set 6 (32×32 frames). Keep pixel-art style/outline consistent with main cast.',\n                'guest_role_1': 'Fallback static guest avatar 1. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_2': 'Fallback static guest avatar 2. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_3': 'Fallback static guest avatar 3. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_4': 'Fallback static guest avatar 4. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_5': 'Fallback static guest avatar 5. Keep design aligned with corresponding guest_anim for smooth fallback.',\n                'guest_role_6': 'Fallback static guest avatar 6. Keep design aligned with corresponding guest_anim for smooth fallback.'\n            },\n            ja: {\n                'office_bg_small': 'メイン背景（現在有効）。1280×720（16:9）推奨。部屋構造と視点を維持し、キャラの位置ズレを防いでください。',\n                'office_bg': '旧背景のバックアップ。通常は直接反映されません。office_bg_small と同構図で保持すると復旧しやすいです。',\n                'star-idle-v5': 'メイン待機スプライトシート。256×256 分割とグリッド構成を維持しないと待機アニメが崩れます。',\n                'star-working-spritesheet-grid': 'メイン作業スプライトシート（デスク状態）。300×300 分割を維持し、重心位置を揃えてください。',\n                'sync-animation': '同期状態素材（現在参照中）。256×256 仕様を守ると静止/ガタつきを回避できます。',\n                'sync-animation-v3-grid': '同期スプライトシート（互換用）。256×256 グリッド維持で同期演出を差し替え可能です。',\n                'error-bug-spritesheet-grid': 'エラー状態スプライトシート。220×220 分割を維持し、視認性の高い配色を推奨。',\n                'desk-v3': 'デスク前景レイヤー。キャラとの前後関係に影響するため、比率と視覚アンカーを維持してください。',\n                'desk': '旧デスク素材（互換）。desk-v3 に近いサイズ/アンカーで差し替えると崩れにくいです。',\n                'sofa-idle-v3': 'ソファ静止素材。256×256 と透過背景を維持し、位置ズレを防いでください。',\n                'sofa-shadow-v1': 'ソファ影レイヤー。本体と同座標に重ねると接地感が出ます。',\n                'memo-bg': 'メモパネル背景。文字可読域を確保し、細かすぎる模様は避けてください。',\n                'plants-spritesheet': '植物ランダム素材。160×160 分割を維持すると複数の植物表示を一括更新できます。',\n                'posters-spritesheet': 'ポスターランダム素材。160×160 分割を維持し、壁面の統一感を意識してください。',\n                'cats-spritesheet': '猫ランダム素材。160×160 分割を維持し、シルエットを明確にすると見分けやすいです。',\n                'coffee-machine-v3-grid': 'コーヒーマシン静止素材。230×230 サイズとアンカーを維持してください。',\n                'coffee-machine-shadow-v1': 'コーヒーマシン影レイヤー。本体と幅・アンカーを揃えると接地感が出ます。',\n                'serverroom-spritesheet': 'サーバールームアニメ素材。180×251 分割を維持し、過度な点滅は避けてください。',\n                'flowers-bloom-v2': '花ランダム素材。128×128 分割を維持し、全体の色調と合わせると馴染みます。',\n                'guest_anim_1': '訪客アニメセット 1（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_2': '訪客アニメセット 2（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_3': '訪客アニメセット 3（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_4': '訪客アニメセット 4（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_5': '訪客アニメセット 5（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_anim_6': '訪客アニメセット 6（32×32 分割）。ピクセル感と輪郭太さを既存キャラに合わせてください。',\n                'guest_role_1': '訪客静止フォールバック画像 1。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_2': '訪客静止フォールバック画像 2。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_3': '訪客静止フォールバック画像 3。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_4': '訪客静止フォールバック画像 4。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_5': '訪客静止フォールバック画像 5。対応する guest_anim とデザインを揃えると切替時に自然です。',\n                'guest_role_6': '訪客静止フォールバック画像 6。対応する guest_anim とデザインを揃えると切替時に自然です。'\n            }\n        };\n\n        function getAssetHelpText(path) {\n            const stem = toAssetStem(path);\n            const lang = (uiLang || 'zh');\n            const map = ASSET_HELP_TEXT_MAP[lang] || ASSET_HELP_TEXT_MAP.zh;\n            return map[stem] || t('assetHintDefault');\n        }\n\n        function renderSelectedAssetGuidance(path, inScene = null) {\n            const out = document.getElementById('asset-upload-result');\n            if (!out) return;\n            if (!path) { out.innerHTML = ''; return; }\n            const displayName = getAssetDisplayName(path);\n            const line1 = `📌 ${displayName}（${path}）`;\n            const line2 = `💡 ${getAssetHelpText(path)}`;\n            const line3 = (inScene === false) ? `⚠️ ${t('assetHintNotInScene')}` : '';\n            out.innerHTML = [line1, line2, line3]\n                .filter(Boolean)\n                .map(v => `<p class=\"hint-p\">${v}</p>`)\n                .join('');\n        }\n\n        function pathToTextureCandidates(path) {\n            const file = (path || '').split('/').pop() || '';\n            const stem = file.replace(/\\.[^.]+$/, '');\n            const map = {\n                'office_bg_small': 'office_bg',\n                'star-idle-v5': 'star_idle',\n                'sofa-idle-v3': 'sofa_idle',\n                'sofa-shadow-v1': 'sofa_shadow',\n                'plants-spritesheet': 'plants',\n                'posters-spritesheet': 'posters',\n                'coffee-machine-v3-grid': 'coffee_machine',\n                'coffee-machine-shadow-v1': 'coffee_machine_shadow',\n                'serverroom-spritesheet': 'serverroom',\n                'error-bug-spritesheet-grid': 'error_bug',\n                'cats-spritesheet': 'cats',\n                'desk-v3': 'desk_v2',\n                'desk': 'desk',\n                'star-working-spritesheet-grid': 'star_working',\n                'sync-animation-v3-grid': 'sync_anim',\n                'memo-bg': 'memo_bg',\n                'flowers-bloom-v2': 'flowers',\n            };\n            const cands = [];\n            if (map[stem]) cands.push(map[stem]);\n            cands.push(stem.replace(/-/g, '_'));\n            cands.push(stem);\n            return [...new Set(cands)];\n        }\n\n        function getCurrentScene() {\n            if (!game) return null;\n            if (game.children && game.add) return game;\n            if (game.scene && game.scene.scenes && game.scene.scenes.length) return game.scene.scenes[0];\n            return null;\n        }\n\n        function getSceneChildren() {\n            const scene = getCurrentScene();\n            return (scene && scene.children && scene.children.list) ? scene.children.list : [];\n        }\n\n        function resolveAssetPathByTextureKey(key) {\n            if (!key) return null;\n            const keyToStem = {\n                office_bg: 'office_bg_small',\n                star_idle: 'star-idle-v5',\n                sofa_idle: 'sofa-idle-v3',\n                sofa_shadow: 'sofa-shadow-v1',\n                plants: 'plants-spritesheet',\n                posters: 'posters-spritesheet',\n                coffee_machine: 'coffee-machine-v3-grid',\n                coffee_machine_shadow: 'coffee-machine-shadow-v1',\n                serverroom: 'serverroom-spritesheet',\n                error_bug: 'error-bug-spritesheet-grid',\n                cats: 'cats-spritesheet',\n                desk_v2: 'desk-v3',\n                desk: 'desk',\n                star_working: 'star-working-spritesheet-grid',\n                sync_anim: 'sync-animation-v3-grid',\n                memo_bg: 'memo-bg',\n                flowers: 'flowers-bloom-v2',\n            };\n            const stem = keyToStem[key] || key.replace(/_/g, '-');\n            const cands = assetListData.filter(it => (it.path || '').includes(stem + '.'));\n            const extPriority = ['.webp', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.avif'];\n            for (const ext of extPriority) {\n                const hit = cands.find(it => (it.path || '').endsWith(ext));\n                if (hit) return hit.path;\n            }\n            return cands[0]?.path || null;\n        }\n\n        function buildSceneAssetItems() {\n            const children = getSceneChildren();\n            const byKey = new Map();\n            for (const ch of children) {\n                const key = ch && ch.texture && ch.texture.key;\n                if (!key) continue;\n                if (!byKey.has(key)) byKey.set(key, ch);\n            }\n            const items = [];\n            for (const [key, ref] of byKey.entries()) {\n                const path = resolveAssetPathByTextureKey(key);\n                if (!path) continue;\n                const meta = assetListData.find(x => x.path === path) || {};\n                items.push({ id: `k:${key}`, key, path, ref, ext: meta.ext || '', size: meta.size || 0, width: meta.width || null, height: meta.height || null });\n            }\n            sceneAssetItems = items.sort((a, b) => a.key.localeCompare(b.key));\n        }\n\n        function mapAssetPathToSprite(path) {\n            // 背景做特殊映射：即使纹理 key 已变成 office_bg_live_xxx，也能稳定定位到背景对象\n            if ((path || '').includes('office_bg_small.webp') && officeBgSprite) return officeBgSprite;\n\n            const item = sceneAssetItems.find(x => x.path === path && x.ref && x.ref.getBounds);\n            if (item) return item.ref;\n            const cands = pathToTextureCandidates(path);\n            const children = getSceneChildren();\n            for (const ch of children) {\n                const key = ch && ch.texture && ch.texture.key;\n                if (key && cands.includes(key)) return ch;\n            }\n            return null;\n        }\n\n        function highlightSpriteByAssetPath(path) {\n            const hl = document.getElementById('asset-highlight');\n            if (!hl || !game || !game.canvas) return false;\n            const sp = mapAssetPathToSprite(path);\n            if (!sp || !sp.getBounds) {\n                hl.style.display = 'none';\n                return false;\n            }\n            const b = sp.getBounds();\n            const canvasRect = game.canvas.getBoundingClientRect();\n            const scaleX = canvasRect.width / config.width;\n            const scaleY = canvasRect.height / config.height;\n            hl.style.display = 'block';\n            hl.style.left = (canvasRect.left + b.x * scaleX) + 'px';\n            hl.style.top = (canvasRect.top + b.y * scaleY) + 'px';\n            hl.style.width = Math.max(24, b.width * scaleX) + 'px';\n            hl.style.height = Math.max(24, b.height * scaleY) + 'px';\n            return true;\n        }\n\n        function drawSelectionBoxOnScene(path) {\n            const scene = getCurrentScene();\n            if (!scene) return false;\n            const sp = mapAssetPathToSprite(path);\n            if (!sp || !sp.getBounds) {\n                if (selectionBoxGraphics) selectionBoxGraphics.setVisible(false);\n                return false;\n            }\n            if (!selectionBoxGraphics) selectionBoxGraphics = scene.add.graphics();\n            const b = sp.getBounds();\n            selectionBoxGraphics.clear();\n            selectionBoxGraphics.lineStyle(4, 0x22c55e, 1);\n            selectionBoxGraphics.strokeRect(b.x, b.y, b.width, b.height);\n            selectionBoxGraphics.setDepth(999999);\n            selectionBoxGraphics.setVisible(true);\n            return true;\n        }\n\n\n        function getLiveFrameSizeByAssetPath(path) {\n            try {\n                const sprite = mapAssetPathToSprite(path);\n                if (sprite && sprite.frame) {\n                    const w = Number(sprite.frame.width || 0);\n                    const h = Number(sprite.frame.height || 0);\n                    if (w > 0 && h > 0) return { w, h };\n                }\n            } catch (e) {}\n            return null;\n        }\n\n        function saveAssetPositionOverrides() { /* deprecated: backend only */ }\n\n        async function applySavedPositionOverrides() {\n            try {\n                // 优先：后端持久化坐标；回退：后端默认坐标；最后：本地内存覆盖\n                let serverPositions = {};\n                let serverDefaults = {};\n                try {\n                    const res = await fetch('/assets/positions?t=' + Date.now(), { cache: 'no-store' });\n                    const data = await res.json();\n                    if (data && data.ok && data.items) serverPositions = data.items;\n                } catch (e) {}\n                try {\n                    const res2 = await fetch('/assets/defaults?t=' + Date.now(), { cache: 'no-store' });\n                    const data2 = await res2.json();\n                    if (data2 && data2.ok && data2.items) serverDefaults = data2.items;\n                } catch (e) {}\n\n                const children = getSceneChildren();\n                for (const ch of children) {\n                    const texKey = ch?.texture?.key;\n                    if (!texKey) continue;\n\n                    // 先尝试资产路径命中（推荐持久化键，优先级最高）\n                    const assetPath = resolveAssetPathByTextureKey(texKey);\n                    let ov = null;\n                    if (assetPath) {\n                        ov = serverPositions[assetPath] || serverDefaults[assetPath] || assetPositionOverrides[assetPath];\n                    }\n\n                    // 再尝试 textureKey 命中（兼容旧数据）\n                    if (!ov) {\n                        ov = serverPositions[texKey] || serverDefaults[texKey] || assetPositionOverrides[texKey];\n                    }\n\n                    // 最后按 stem 模糊匹配（处理 webp/png 或 live key 差异）\n                    if (!ov) {\n                        const stem = toAssetStem(assetPath || texKey);\n                        const hitKey = Object.keys(serverPositions).find(k => toAssetStem(k) === stem)\n                            || Object.keys(serverDefaults).find(k => toAssetStem(k) === stem)\n                            || Object.keys(assetPositionOverrides).find(k => toAssetStem(k) === stem);\n                        if (hitKey) ov = serverPositions[hitKey] || serverDefaults[hitKey] || assetPositionOverrides[hitKey];\n                    }\n\n                    if (!ov) continue;\n                    const x = Number(ov.x), y = Number(ov.y), sc = Number(ov.scale || 1);\n                    if (Number.isFinite(x) && Number.isFinite(y)) {\n                        ch.x = x;\n                        ch.y = y;\n                        if (Number.isFinite(sc) && sc > 0 && ch.setScale) ch.setScale(sc);\n                    }\n                }\n            } catch (e) {}\n        }\n\n        function clearAssetSelectionUI() {\n            const hl = document.getElementById('asset-highlight');\n            if (hl) hl.style.display = 'none';\n            if (selectionBoxGraphics) selectionBoxGraphics.setVisible(false);\n        }\n\n        function clearAssetSelection(resetInputs = true) {\n            selectedAssetInfo = null;\n            updateActiveAssetItem('');\n            clearAssetSelectionUI();\n            const out = document.getElementById('asset-upload-result');\n            if (out) out.textContent = '';\n\n            updateAssetConfirmButtonState();\n        }\n\n        function applyScenePreview(path) {\n            const ok = highlightSpriteByAssetPath(path);\n            const ok2 = drawSelectionBoxOnScene(path);\n            return !!(ok && ok2);\n        }\n\n        function updateActiveAssetItem(path) {\n            document.querySelectorAll('#asset-list .asset-item').forEach(el => {\n                const p = el.getAttribute('data-path');\n                el.classList.toggle('active', p === path);\n            });\n        }\n\n        function updateAssetConfirmButtonState() {\n            const btn = document.getElementById('asset-commit-refresh-btn');\n            const btnReset = document.getElementById('asset-reset-default-btn');\n            const btnPrev = document.getElementById('asset-restore-prev-btn');\n            const panel = document.getElementById('asset-upload-panel');\n            const can = !!(selectedAssetInfo && selectedAssetInfo.path);\n            if (panel) panel.classList.toggle('active', can);\n            [btn, btnReset, btnPrev].forEach((b) => {\n                if (!b) return;\n                b.disabled = !can;\n                b.style.opacity = can ? '1' : '.55';\n            });\n        }\n\n        function selectAssetInDrawer(path) {\n            // 二次点击同一资产 = 取消选择\n            if (selectedAssetInfo && selectedAssetInfo.path === path) {\n                clearAssetSelection(true);\n                return;\n            }\n            selectedAssetInfo = assetListData.find(x => x.path === path) || null;\n            updateActiveAssetItem(path);\n            const ok = applyScenePreview(path);\n            renderSelectedAssetGuidance(path, ok);\n            updateAssetConfirmButtonState();\n        }\n\n        function clearAssetThumbTimers() {\n            assetThumbTimers.forEach(t => clearInterval(t));\n            assetThumbTimers = [];\n        }\n\n        function inferSpritesheetFrameMetaByPath(path) {\n            const p = (path || '').toLowerCase();\n            if (!p) return null;\n            // 优先用文件命名约定推断（不写死具体尺寸）\n            if (p.includes('spritesheet') || p.includes('sprite-sheet') || p.includes('sheet') || p.includes('anim') || p.includes('grid')) {\n                return { w: null, h: null };\n            }\n            return null;\n        }\n\n        function getSpritesheetFrameMeta(item) {\n            // 先看命名是否属于精灵表\n            const inferred = inferSpritesheetFrameMetaByPath(item?.path || '');\n            if (!inferred) return null;\n            // 仅返回“是精灵表”的信号，单帧尺寸后续自动推断\n            return { w: null, h: null, isSheet: true };\n        }\n\n        function guessThumbFrameSize(fullW, fullH, path = '') {\n            const p = (path || '').toLowerCase();\n            // 常见核心资产优先用显式提示（避免误判）\n            const hints = [\n                [/star-working-spritesheet-grid\\.webp$/, 300, 300],\n                [/star-idle-v5\\.(webp|png)$/, 256, 256],\n                [/sync-animation-v3-grid\\.webp$/, 256, 256],\n                [/error-bug-spritesheet-grid\\.webp$/, 220, 220],\n                [/flowers-bloom-v2\\.webp$/, 128, 128],\n                [/plants-spritesheet\\.webp$/, 160, 160]\n            ];\n            for (const [re, fw, fh] of hints) {\n                if (re.test(p) && fullW % fw === 0 && fullH % fh === 0) return { fw, fh };\n            }\n\n            // 通用推断：枚举可整除候选，偏好 cols≈8、帧尺寸适中、近似方形\n            const divisors = (n) => {\n                const arr = [];\n                for (let i = 1; i * i <= n; i++) {\n                    if (n % i === 0) {\n                        arr.push(i);\n                        if (i * i !== n) arr.push(n / i);\n                    }\n                }\n                return arr.sort((a, b) => a - b);\n            };\n            const fwCand = divisors(fullW).filter(v => v >= 48 && v <= 512);\n            const fhCand = divisors(fullH).filter(v => v >= 48 && v <= 512);\n            let best = null;\n            for (const fw of fwCand) {\n                for (const fh of fhCand) {\n                    const cols = fullW / fw;\n                    const rows = fullH / fh;\n                    if (!Number.isInteger(cols) || !Number.isInteger(rows)) continue;\n                    const frames = cols * rows;\n                    if (frames <= 1 || cols < 2 || rows < 1) continue;\n                    let score = 0;\n                    if (cols === 8) score += 120;\n                    else if (cols >= 4 && cols <= 10) score += 45;\n                    if (rows >= 1 && rows <= 10) score += 25;\n                    score += Math.min(frames, 120) * 0.8;\n                    score -= Math.abs(fw - fh) * 0.12;\n                    if (fw === fullW || fh === fullH) score -= 80;\n                    if (!best || score > best.score) best = { fw, fh, score };\n                }\n            }\n            return best ? { fw: best.fw, fh: best.fh } : null;\n        }\n\n        function tryAnimateAssetThumb(item) {\n            if (!item) return;\n            const canvas = document.getElementById(`asset-thumb-canvas-${(item.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`);\n            if (!canvas) return;\n            const ctx = canvas.getContext('2d');\n            if (!ctx) return;\n\n            const img = new Image();\n            img.onload = () => {\n                const fullW = img.naturalWidth || img.width;\n                const fullH = img.naturalHeight || img.height;\n                const meta = getSpritesheetFrameMeta(item);\n                if (!meta) return;\n                const guessed = guessThumbFrameSize(fullW, fullH, item?.path || '');\n                if (!guessed) return;\n                const fw = guessed.fw;\n                const fh = guessed.fh;\n\n                // 判断是否可能是精灵表：整图宽高至少是单帧的整数倍，且总帧数>1\n                const cols = Math.floor(fullW / fw);\n                const rows = Math.floor(fullH / fh);\n                const frames = cols * rows;\n                if (cols < 1 || rows < 1 || frames <= 1) return;\n\n                let idx = 0;\n                const draw = () => {\n                    const cx = (idx % cols) * fw;\n                    const cy = Math.floor(idx / cols) * fh;\n                    ctx.clearRect(0, 0, canvas.width, canvas.height);\n                    ctx.imageSmoothingEnabled = false;\n                    ctx.drawImage(img, cx, cy, fw, fh, 0, 0, canvas.width, canvas.height);\n                    idx = (idx + 1) % frames;\n                };\n                draw();\n                const timer = setInterval(draw, 120);\n                assetThumbTimers.push(timer);\n            };\n            img.src = `/static/${item.path}?t=${Date.now()}`;\n        }\n\n        function isAssetHidden(path) {\n            return hiddenAssetPaths.has(path || '');\n        }\n\n        function setAssetVisible(path, visible) {\n            const p = (path || '').trim();\n            if (!p) return;\n            if (visible) hiddenAssetPaths.delete(p);\n            else hiddenAssetPaths.add(p);\n\n            const sp = mapAssetPathToSprite(p);\n            if (sp && sp.setVisible) {\n                sp.setVisible(!!visible);\n            }\n        }\n\n        function toggleAssetVisibility(path, ev) {\n            if (ev && ev.stopPropagation) ev.stopPropagation();\n            const p = (path || '').trim();\n            if (!p) return;\n            const nextVisible = isAssetHidden(p);\n            setAssetVisible(p, nextVisible);\n            renderAssetDrawerList();\n            const out = document.getElementById('asset-upload-result');\n            if (out) out.textContent = nextVisible ? `✅ 已显示：${p}` : `🙈 已隐藏：${p}`;\n            if (selectedAssetInfo && selectedAssetInfo.path === p) {\n                if (!nextVisible) clearAssetSelectionUI();\n                else applyScenePreview(p);\n            }\n        }\n\n        function renderAssetDrawerList() {\n            const q = (document.getElementById('asset-search')?.value || '').trim().toLowerCase();\n            const list = document.getElementById('asset-list');\n            if (!list) return;\n\n            // 统一显示后端全部资产（不再区分已加载/全部）\n            const baseRows = assetListData.map(it => ({ ...it, key: '' }));\n\n            const statePriority = [\n                'star-idle-v5.png',\n                'star-working-spritesheet-grid.webp',\n                'sync-animation-v3-grid.webp',\n                'error-bug-spritesheet-grid.webp'\n            ];\n            const assetRank = (path='') => {\n                const p = (path || '').toLowerCase();\n                const idx = statePriority.findIndex(x => p.endsWith(x));\n                if (idx >= 0) return idx; // 0~3: 四个主状态最前\n\n                // 按钮素材最不重要：统一沉到列表末尾\n                if (p.includes('/btn-') || p.includes('btn-') || p.includes('button')) return 1000;\n\n                if (p.includes('guest_anim_')) return 999; // guest 动画靠后\n                return 100;\n            };\n            const rows = baseRows\n                .filter(it => !q || (it.path || '').toLowerCase().includes(q) || (it.key || '').toLowerCase().includes(q))\n                .sort((a,b)=> {\n                    const ra = assetRank(a.path), rb = assetRank(b.path);\n                    if (ra !== rb) return ra - rb;\n                    return (a.path || '').localeCompare(b.path || '');\n                });\n\n            clearAssetThumbTimers();\n\n            if (rows.length === 0) {\n                list.innerHTML = '<div class=\"asset-sub\" style=\"padding:8px\">暂无资产（可点“刷新”重试）</div>';\n                return;\n            }\n\n            list.innerHTML = rows.map(it => {\n                const isActive = ((selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '') === it.path;\n                const reso = (it.width && it.height) ? `${it.width}×${it.height}` : '-';\n                const displayName = getAssetDisplayName(it.path || '');\n                const thumbId = `asset-thumb-canvas-${(it.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`;\n                const hidden = isAssetHidden(it.path);\n                const visEmoji = hidden ? '🙈' : '👀';\n                return `<div class=\"asset-item ${isActive ? 'active' : ''}\" data-path=\"${it.path}\" onclick=\"selectAssetInDrawer('${(it.path || '').replace(/'/g, \"\\\\'\")}')\">\n                    <canvas id=\"${thumbId}\" class=\"asset-thumb\" width=\"56\" height=\"56\"></canvas>\n                    <div class=\"asset-meta\">\n                        <div class=\"asset-path\">${it.path}</div>\n                        <div class=\"asset-sub\">${displayName} ｜ ${reso}${hidden ? ' ｜ 已隐藏' : ''}</div>\n                    </div>\n                    <button class=\"asset-vis-btn\" onclick=\"toggleAssetVisibility('${(it.path || '').replace(/'/g, \"\\\\'\")}', event)\">${visEmoji}</button>\n                </div>`;\n            }).join('');\n\n            // 先画静态缩略图，再尝试对精灵表做逐帧预览\n            rows.forEach(it => {\n                const canvas = document.getElementById(`asset-thumb-canvas-${(it.path || '').replace(/[^a-zA-Z0-9]/g, '_')}`);\n                if (!canvas) return;\n                const ctx = canvas.getContext('2d');\n                if (!ctx) return;\n                const img = new Image();\n                img.onload = () => {\n                    ctx.clearRect(0, 0, canvas.width, canvas.height);\n                    ctx.imageSmoothingEnabled = false;\n                    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n                    tryAnimateAssetThumb(it);\n                };\n                img.src = `/static/${it.path}?t=${Date.now()}`;\n            });\n        }\n\n        async function refreshAssetDrawerList() {\n            const out = document.getElementById('asset-upload-result');\n            try {\n                const selectedPath = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';\n                const res = await fetch('/assets/list?t=' + Date.now(), { cache: 'no-store' });\n                const data = await res.json();\n                assetListData = data.items || [];\n\n                // 场景渲染可能稍晚，做一次延迟抓取\n                buildSceneAssetItems();\n                if (sceneAssetItems.length === 0) {\n                    setTimeout(() => {\n                        buildSceneAssetItems();\n                        renderAssetDrawerList();\n                    }, 500);\n                }\n\n                renderAssetDrawerList();\n                if (out) out.textContent = `已加载资产：${assetListData.length} ｜ 场景抓取：${sceneAssetItems.length}`;\n\n                if (selectedPath) {\n                    updateActiveAssetItem(selectedPath);\n                    applyScenePreview(selectedPath);\n                }\n            } catch (e) {\n                console.error('加载资产列表失败', e);\n                if (out) out.textContent = '❌ 资产加载失败，请点“刷新”重试';\n            }\n        }\n\n        function bindDrawerFileMeta() {\n            const input = document.getElementById('asset-upload-file');\n            const out = document.getElementById('asset-upload-result');\n            if (!input || !out) return;\n            input.onchange = () => {\n                const f = input.files && input.files[0];\n                const targetPath = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';\n                if (!f) {\n                    if (targetPath) {\n                        const inScene = !!applyScenePreview(targetPath);\n                        renderSelectedAssetGuidance(targetPath, inScene);\n                    } else {\n                        out.textContent = '';\n                    }\n                    updateAssetConfirmButtonState();\n                    return;\n                }\n                const targetLabel = targetPath || '-';\n                const pending = `${t('uploadPending')}：${f.name} ｜ ${formatSizeHuman(f.size)} ｜ ${t('uploadTarget')}：${targetLabel}`;\n                if (targetPath) {\n                    const inScene = !!mapAssetPathToSprite(targetPath);\n                    const displayName = getAssetDisplayName(targetPath);\n                    const hint = getAssetHelpText(targetPath);\n                    const warn = inScene ? '' : `⚠️ ${t('assetHintNotInScene')}`;\n                    out.innerHTML = [\n                        `<p class=\"hint-p\">${pending}</p>`,\n                        `<p class=\"hint-p\">📌 ${displayName}（${targetPath}）</p>`,\n                        `<p class=\"hint-p\">💡 ${hint}</p>`,\n                        warn ? `<p class=\"hint-p\">${warn}</p>` : ''\n                    ].filter(Boolean).join('');\n                } else {\n                    out.innerHTML = `<p class=\"hint-p\">${pending}</p>`;\n                }\n                updateAssetConfirmButtonState();\n            };\n            updateAssetConfirmButtonState();\n        }\n\n        let assetDrawerBackgroundBinded = false;\n        function bindAssetDrawerBackgroundDeselect() {\n            if (assetDrawerBackgroundBinded) return;\n            assetDrawerBackgroundBinded = true;\n            const body = document.getElementById('asset-drawer-body');\n            if (!body) return;\n            body.addEventListener('click', (e) => {\n                if (!assetDrawerOpen || !assetDrawerAuthed) return;\n                // 点击空白处才取消选择；点击控件/资产项不取消\n                const keep = e.target.closest('.asset-item, .asset-toolbar, #asset-upload-panel, #asset-move-panel, button, input, textarea, label, canvas');\n                if (keep) return;\n                clearAssetSelection(true);\n            });\n        }\n\n        function openInlineAssetUploader() {\n            const input = document.getElementById('asset-upload-file');\n            if (!input) return;\n            input.click();\n        }\n        async function refreshSceneObjectByAssetPath(path) {\n            const scene = getCurrentScene();\n            if (!scene || !path) return false;\n\n            const sprite = mapAssetPathToSprite(path);\n            if (!sprite || !sprite.texture) return false;\n\n            const oldKey = sprite.texture.key;\n            const ext = path.split('.').pop();\n            const newKey = `${oldKey}_live_${Date.now()}`;\n            const url = `/static/${path}?t=${Date.now()}`;\n\n            return new Promise((resolve) => {\n                try {\n                    scene.load.once('complete', () => {\n                        try {\n                            // 替换到新纹理\n                            if (sprite.setTexture) sprite.setTexture(newKey);\n                            // 同 key 角色（如多个同材质装饰）一起替换\n                            getSceneChildren().forEach(ch => {\n                                if (ch !== sprite && ch.texture && ch.texture.key === oldKey && ch.setTexture) {\n                                    ch.setTexture(newKey);\n                                }\n                            });\n                            // 更新背景引用\n                            if (oldKey === 'office_bg' && officeBgSprite && officeBgSprite.texture && officeBgSprite.texture.key === newKey) {\n                                currentOfficeBgTextureKey = newKey;\n                            }\n                            // 移除旧纹理，避免内存堆积\n                            if (oldKey !== newKey && scene.textures.exists(oldKey)) {\n                                scene.textures.remove(oldKey);\n                            }\n                            resolve(true);\n                        } catch (e) {\n                            console.warn('替换场景纹理失败(setTexture):', e);\n                            resolve(false);\n                        }\n                    });\n                    scene.load.once('loaderror', () => resolve(false));\n\n                    // 按扩展名用对应 loader\n                    if (ext === 'json') {\n                        resolve(false);\n                        return;\n                    }\n                    scene.load.image(newKey, url);\n                    scene.load.start();\n                } catch (e) {\n                    console.warn('替换场景纹理失败(load):', e);\n                    resolve(false);\n                }\n            });\n        }\n\n        async function commitAssetUpdate() {\n            const path = (selectedAssetInfo && selectedAssetInfo.path) ? selectedAssetInfo.path : '';\n            const fi = document.getElementById('asset-upload-file');\n            const out = document.getElementById('asset-upload-result');\n            if (!path) { out.textContent = '请先选中一个资产路径'; return false; }\n            if (!fi.files.length) { return true; } // 允许仅改坐标\n            const file = fi.files[0];\n            const fd = new FormData();\n            fd.append('path', path);\n            fd.append('backup', '1');\n            fd.append('file', file);\n\n            const nameLower = (file.name || '').toLowerCase();\n            const isAnimInput = nameLower.endsWith('.gif') || nameLower.endsWith('.webp');\n            const isSheetTarget = !!inferSpritesheetFrameMetaByPath(path);\n\n            if (isSheetTarget) {\n                fd.append('auto_spritesheet', '1');\n                // 全自动：后端识别并切帧\n                if (isAnimInput) {\n                    fd.append('preserve_original', '1');\n                } else {\n                    // 静态图兜底切法\n                    fd.append('frame_w', '64');\n                    fd.append('frame_h', '64');\n                    fd.append('preserve_original', '0');\n                }\n                fd.append('pixel_art', '1');\n            }\n\n            out.textContent = '⏳ 正在上传并替换，请稍候...';\n            const res = await fetch('/assets/upload', { method: 'POST', body: fd });\n            const data = await res.json();\n            if (!data.ok) {\n                out.textContent = `❌ 更新失败：${data.msg || res.status}`;\n                return false;\n            }\n\n            if (data.converted) {\n                const toType = data.converted.to || 'spritesheet';\n                out.textContent = `✅ 已上传（动图→${toType}）：${data.path} ｜ ${data.converted.frames}帧 ${data.converted.frame_w}x${data.converted.frame_h}`;\n            } else {\n                out.textContent = `✅ 已上传：${data.path}`;\n            }\n            return true;\n        }\n\n        async function commitAndRefresh() {\n            const out = document.getElementById('asset-upload-result');\n            const fi = document.getElementById('asset-upload-file');\n            const hasFile = !!(fi && fi.files && fi.files.length > 0);\n\n            const okUpload = await commitAssetUpdate();\n            if (!okUpload) return;\n\n            if (out) {\n                if (hasFile) out.textContent += ' ｜ ✅ 已上传并刷新';\n                else out.textContent = '✅ 已确认并刷新';\n            }\n\n            // 刷新前关闭侧边栏，行为与地图替换一致\n            assetDrawerOpen = false;\n            const drawer = document.getElementById('asset-drawer');\n            if (drawer) drawer.classList.remove('open');\n\n            setTimeout(() => window.location.reload(), 400);\n        }\n\nfunction toggleBrokerPanel() {\n            const btn = document.querySelector('#asset-broker-row .btn-broker');\n            flashButtonActive(btn);\n            const p = document.getElementById('asset-broker-panel');\n            if (!p) return;\n            p.classList.toggle('open');\n        }\n\n        function toggleManualPanel() {\n            const btn = document.querySelector('#asset-broker-row .btn-diy');\n            flashButtonActive(btn);\n            assetManualPanelOpen = !assetManualPanelOpen;\n            updateManualPanelUI();\n        }\n\n        function placeOverlayAndStatusAtCanvasBottomLeft() {\n            const canvasEl = game?.canvas || document.querySelector('#game-container canvas');\n            const fallbackBox = document.getElementById('game-container');\n            const rect = canvasEl?.getBoundingClientRect?.() || fallbackBox?.getBoundingClientRect?.();\n\n            // 1) loading 遮罩\n            const overlay = document.getElementById('room-loading-overlay');\n            if (overlay) {\n                if (!rect || !(rect.width > 0 && rect.height > 0)) {\n                    overlay.style.left = '0px';\n                    overlay.style.top = '0px';\n                    overlay.style.width = window.innerWidth + 'px';\n                    overlay.style.height = window.innerHeight + 'px';\n                } else {\n                    overlay.style.left = rect.left + 'px';\n                    overlay.style.top = rect.top + 'px';\n                    overlay.style.width = rect.width + 'px';\n                    overlay.style.height = rect.height + 'px';\n                }\n            }\n\n            // 2) detail/status 严格限制在画布内部左下角\n            const st = document.getElementById('status-text');\n            const gameContainer = document.getElementById('game-container');\n            if (st && gameContainer) {\n                if (rect && rect.width > 0 && rect.height > 0) {\n                    const localLeft = Math.max(8, Math.round(rect.left - gameContainer.getBoundingClientRect().left + 14));\n                    const localBottom = 14;\n                    st.style.left = localLeft + 'px';\n                    st.style.bottom = localBottom + 'px';\n                    st.style.maxWidth = Math.max(120, Math.floor(rect.width - 28)) + 'px';\n                } else {\n                    st.style.left = '14px';\n                    st.style.bottom = '14px';\n                    st.style.maxWidth = 'calc(100% - 28px)';\n                }\n            }\n        }\n\n        function showRoomLoadingOverlay(baseText) {\n            const overlay = document.getElementById('room-loading-overlay');\n            const textEl = document.getElementById('room-loading-text');\n            const emojiEl = document.getElementById('room-loading-emoji');\n            if (!overlay || !textEl || !emojiEl) return;\n\n            placeOverlayAndStatusAtCanvasBottomLeft();\n            const loadingTexts = {\n                zh: [\n                    '正在打包今天的灵感行李……',\n                    '正在抽取下一站数字坐标……',\n                    '正在查看本次漂流目的地……',\n                    '正在把办公室折叠成随身模式……',\n                    '正在给钳子装上远行 Buff……',\n                    '正在匹配下一段创作气候……',\n                    '正在把时差调成冒险模式……',\n                    '正在接收陌生街区的 Wi‑Fi 心跳……',\n                    '正在试播下一站的海风 BGM……',\n                    '正在加载“也许会爱上”的新房间……',\n                    '正在为未知邻居准备自我介绍……',\n                    '正在解锁下一片数字海域……',\n                    '正在把好奇心调到满格……',\n                    '正在等待旅程投递下一张门牌号……'\n                ],\n                en: [\n                    'Packing today’s luggage of inspiration…',\n                    'Drawing the digital coordinates for the next stop…',\n                    'Checking the destination of this drift…',\n                    'Folding the office into portable mode…',\n                    'Installing a travel buff on the claws…',\n                    'Matching the creative climate for the next chapter…',\n                    'Switching the time zone to adventure mode…',\n                    'Receiving Wi‑Fi heartbeats from an unfamiliar block…',\n                    'Previewing the sea-breeze BGM of the next stop…',\n                    'Loading a new room you might just fall in love with…',\n                    'Preparing an intro for unknown neighbors…',\n                    'Unlocking the next digital sea…',\n                    'Turning curiosity up to max…',\n                    'Waiting for the journey to deliver the next door number…'\n                ],\n                ja: [\n                    '今日のひらめき荷物を梱包しています……',\n                    '次の目的地のデジタル座標を抽出しています……',\n                    '今回の漂流先を確認しています……',\n                    'オフィスを携帯モードに折りたたんでいます……',\n                    'ハサミに遠征 Buff を装着しています……',\n                    '次の創作区間の気候をマッチングしています……',\n                    '時差を冒険モードに切り替えています……',\n                    '見知らぬ街区の Wi‑Fi ハートビートを受信しています……',\n                    '次の目的地の潮風 BGM を試聴しています……',\n                    '「好きになるかもしれない」新しい部屋を読み込んでいます……',\n                    '未知のご近所さん向けに自己紹介を準備しています……',\n                    '次のデジタル海域をアンロックしています……',\n                    '好奇心を最大値まで上げています……',\n                    '旅が次の番地を届けるのを待っています……'\n                ]\n            };\n            const steps = loadingTexts[uiLang] || loadingTexts.zh;\n            const emojis = ['🦞','🦀','🦐','🦑','🐙','🐟','🐠','🐡','🦪','🍣','🍤','🍱','🍲','🍜','🍝','🌊','🐚','🪸'];\n\n            roomLoadingIndex = 0;\n            roomLoadingEmojiIndex = 0;\n            textEl.textContent = baseText || steps[0];\n            emojiEl.textContent = emojis[0];\n            overlay.style.display = 'flex';\n            if (roomLoadingTimer) clearInterval(roomLoadingTimer);\n            roomLoadingTimer = setInterval(() => {\n                roomLoadingIndex = (roomLoadingIndex + 1) % steps.length;\n                roomLoadingEmojiIndex = (roomLoadingEmojiIndex + 1) % emojis.length;\n                textEl.textContent = steps[roomLoadingIndex];\n                emojiEl.textContent = emojis[roomLoadingEmojiIndex];\n            }, 900);\n        }\n\n        function hideRoomLoadingOverlay() {\n            const overlay = document.getElementById('room-loading-overlay');\n            if (roomLoadingTimer) {\n                clearInterval(roomLoadingTimer);\n                roomLoadingTimer = null;\n            }\n            if (overlay) overlay.style.display = 'none';\n        }\n\n        async function refreshOfficeBackgroundOnly() {\n            return await refreshSceneObjectByAssetPath('office_bg_small.webp');\n        }\n\n        function markMoveSuccess(outEl, btnEl = null) {\n            if (outEl) outEl.textContent = t('moveSuccess');\n            if (btnEl) setButtonDone(btnEl);\n            try { setState('idle', t('moveSuccess').replace('✅ ', '')); } catch (e) {}\n        }\n\n        function setWorkingStatus(detail = '工作中') {\n            try { setState('writing', detail); } catch (e) {}\n        }\n\n        async function ensureGeminiConfigLoaded() {\n            try {\n                const authRes = await fetch('/assets/auth/status', { cache: 'no-store' });\n                const authData = await authRes.json();\n                assetDrawerAuthed = !!(authData && authData.ok && authData.authed);\n                updateAssetAuthUI();\n                if (!assetDrawerAuthed) return;\n\n                const res = await fetch('/config/gemini', { cache: 'no-store' });\n                const data = await res.json();\n                if (data && data.ok) {\n                    window.geminiConfig = {\n                        hasKey: !!data.has_api_key,\n                        model: data.gemini_model || 'nanobanana-pro'\n                    };\n                    const box = document.getElementById('asset-gemini-config');\n                    if (box) box.style.display = 'block';\n                    const ms = document.getElementById('gemini-mask-status');\n                    if (ms) {\n                        ms.textContent = data.has_api_key\n                            ? `${t('geminiMaskHasKey')} ${data.api_key_masked || ''}`\n                            : t('geminiMaskNoKey');\n                    }\n                }\n            } catch (e) {}\n        }\n\n        async function saveGeminiConfigFromUI() {\n            const input = document.getElementById('gemini-api-key-input');\n            const msg = document.getElementById('gemini-config-msg');\n            const key = (input?.value || '').trim();\n            if (!key) {\n                if (msg) msg.textContent = '请输入有效 API Key';\n                return;\n            }\n            try {\n                const res = await fetch('/config/gemini', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ api_key: key, model: 'nanobanana-pro' })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (msg) msg.textContent = `保存失败：${data.msg || res.status}`;\n                    return;\n                }\n                if (msg) msg.textContent = '✅ 已保存，可重新点击搬家/中介';\n                const box = document.getElementById('asset-gemini-config');\n                if (box) box.style.display = 'none';\n                await ensureGeminiConfigLoaded();\n            } catch (e) {\n                if (msg) msg.textContent = `保存失败：${e}`;\n            }\n        }\n\n        function flashButtonActive(el, ms = 180) {\n            if (!el) return;\n            el.classList.add('is-active');\n            setTimeout(() => el.classList.remove('is-active'), ms);\n        }\n\n        function setButtonDone(el, holdMs = 1200) {\n            if (!el) return;\n            el.classList.remove('is-active');\n            el.classList.add('is-done');\n            setTimeout(() => el.classList.remove('is-done'), holdMs);\n        }\n\n        // Async generation: start task then poll for result (avoids Cloudflare 524 timeout)\n        async function _startAndPollGeneration(body, out, progressMsg) {\n            const res = await fetch('/assets/generate-rpg-background', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify(body)\n            });\n            const data = await res.json();\n            if (!data.ok) return data;\n            if (!data.async || !data.task_id) return data; // sync fallback (shouldn't happen)\n\n            // Poll for completion\n            const taskId = data.task_id;\n            const maxPollTime = 300000; // 5 minutes max\n            const pollInterval = 3000;  // 3 seconds\n            const startTime = Date.now();\n            let dots = 0;\n\n            while (Date.now() - startTime < maxPollTime) {\n                await new Promise(r => setTimeout(r, pollInterval));\n                dots = (dots + 1) % 4;\n                const elapsed = Math.round((Date.now() - startTime) / 1000);\n                out.textContent = progressMsg + '（已等待 ' + elapsed + '秒）' + '.'.repeat(dots);\n                try {\n                    const pollRes = await fetch('/assets/generate-rpg-background/poll?task_id=' + encodeURIComponent(taskId));\n                    const pollData = await pollRes.json();\n                    if (pollData.status === 'pending') continue;\n                    return pollData; // done or error\n                } catch (pollErr) {\n                    // Network error during poll, keep trying\n                    continue;\n                }\n            }\n            return { ok: false, msg: '生图超时（超过5分钟），请重试' };\n        }\n\n        function _handleGenError(data, out) {\n            if (data.code === 'MISSING_API_KEY') {\n                out.textContent = t('brokerMissingKey');\n                const box = document.getElementById('asset-gemini-config');\n                if (box) box.style.display = 'block';\n            } else if (data.code === 'API_KEY_REVOKED_OR_LEAKED') {\n                out.textContent = '❌ 当前 API Key 已失效/疑似泄露，请更换新 Key 后重试';\n                const box = document.getElementById('asset-gemini-config');\n                if (box) box.style.display = 'block';\n            } else if (data.code === 'MODEL_NOT_AVAILABLE') {\n                out.textContent = '❌ 当前模型在此通道不可用，请切换可用模型后重试' + (data.detail ? ('\\n\\n详情：' + data.detail) : '');\n            } else {\n                out.textContent = `❌ 生成失败：${data.msg || 'unknown error'}`;\n            }\n        }\n\n        async function generateCustomRpgBackground() {\n            const brokerBtn = document.querySelector('#asset-broker-row .btn-broker');\n            flashButtonActive(brokerBtn);\n            setWorkingStatus('正在处理中介装修方案');\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            const prompt = (document.getElementById('asset-broker-prompt')?.value || '').trim();\n            if (!prompt) {\n                out.textContent = t('brokerNeedPrompt');\n                return;\n            }\n            showRoomLoadingOverlay();\n            out.textContent = t('brokerGenerating');\n            try {\n                const data = await _startAndPollGeneration(\n                    { prompt, speed_mode: speedMode },\n                    out,\n                    '🏘️ 正在按中介方案生成底图'\n                );\n                if (!data.ok) {\n                    _handleGenError(data, out);\n                    return;\n                }\n                out.textContent = t('brokerDone');\n                const ok = await refreshOfficeBackgroundOnly();\n                if (ok) {\n                    markMoveSuccess(out, brokerBtn);\n                } else {\n                    out.textContent = '✅ 已生成并替换底图（局部刷新失败，可手动刷新页面）';\n                }\n            } catch (e) {\n                out.textContent = `❌ 生成失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function generateRpgBackground() {\n            const moveBtn = document.getElementById('btn-move-house');\n            flashButtonActive(moveBtn);\n            setWorkingStatus('正在搬新家');\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            showRoomLoadingOverlay();\n            out.textContent = '🧳 正在打包行李，请稍后（约30~120秒）';\n            try {\n                const data = await _startAndPollGeneration(\n                    { speed_mode: speedMode },\n                    out,\n                    '🧳 正在生成新房间'\n                );\n                if (!data.ok) {\n                    _handleGenError(data, out);\n                    return;\n                }\n                out.textContent = '✅ 已生成并替换底图，正在刷新房间...';\n                const ok = await refreshOfficeBackgroundOnly();\n                if (ok) {\n                    markMoveSuccess(out, moveBtn);\n                } else {\n                    out.textContent = '✅ 已生成并替换底图（局部刷新失败，可手动刷新页面）';\n                }\n            } catch (e) {\n                out.textContent = `❌ 生成失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function restoreHomeBackground() {\n            const homeBtn = document.getElementById('btn-back-home');\n            flashButtonActive(homeBtn);\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n\n            const confirmMsg = '⚠️ 回老家会覆盖当前自定义房间背景（可从 bg-history 恢复历史图）。\\n确定继续吗？';\n            if (!window.confirm(confirmMsg)) {\n                out.textContent = '已取消回老家';\n                return;\n            }\n\n            setWorkingStatus('正在回老家');\n            // 点击即刻显示遮罩，先于任何网络调用\n            showRoomLoadingOverlay();\n            out.textContent = '🏡 正在回老家（恢复初始底图）...';\n            try {\n                const res = await fetch('/assets/restore-reference-background', { method: 'POST' });\n                const data = await res.json();\n                if (!data.ok) {\n                    out.textContent = `❌ 恢复失败：${data.msg || res.status}`;\n                    return;\n                }\n                out.textContent = '✅ 已恢复初始底图';\n                const ok = await refreshOfficeBackgroundOnly();\n                if (ok) {\n                    markMoveSuccess(out, homeBtn);\n                } else {\n                    out.textContent = '✅ 已恢复初始底图（局部刷新失败，可手动刷新页面）';\n                }\n            } catch (e) {\n                out.textContent = `❌ 恢复失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function restoreLastGeneratedBackground() {\n            const btn = document.getElementById('btn-back-last-bg');\n            flashButtonActive(btn);\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n\n            const confirmMsg = '⚠️ 将回退到最近一次生成的房间背景，确定继续吗？';\n            if (!window.confirm(confirmMsg)) {\n                out.textContent = '已取消回退';\n                return;\n            }\n\n            setWorkingStatus('正在回退到上一次背景');\n            showRoomLoadingOverlay();\n            out.textContent = '↩️ 正在回退到最近一次生成底图...';\n            try {\n                const res = await fetch('/assets/restore-last-generated-background', { method: 'POST' });\n                const data = await res.json();\n                if (!data.ok) {\n                    out.textContent = `❌ 回退失败：${data.msg || res.status}`;\n                    return;\n                }\n                const ok = await refreshOfficeBackgroundOnly();\n                if (ok) {\n                    out.textContent = '✅ 已回退到上一次背景';\n                } else {\n                    out.textContent = '✅ 已回退到上一次背景（局部刷新失败，可手动刷新页面）';\n                }\n                try { setState('idle', '已回退到上一次背景'); } catch (e) {}\n            } catch (e) {\n                out.textContent = `❌ 回退失败：${e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function fetchJsonSafe(url, options = {}) {\n            const res = await fetch(url, options);\n            const ct = (res.headers.get('content-type') || '').toLowerCase();\n            if (!ct.includes('application/json')) {\n                const txt = await res.text();\n                const brief = (txt || '').replace(/\\s+/g, ' ').slice(0, 120);\n                throw new Error(`接口未返回 JSON（${res.status}）: ${brief || 'empty response'}`);\n            }\n            return await res.json();\n        }\n\n        async function renderHomeFavorites(force = false) {\n            const box = document.getElementById('asset-home-favorites-list');\n            if (!box) return;\n            const now = Date.now();\n            if (!force && homeFavoritesCache.length > 0 && (now - homeFavoritesLoadedAt) < 30000) {\n                // 使用缓存，避免频繁请求\n            } else {\n                try {\n                    const data = await fetchJsonSafe('/assets/home-favorites/list', { cache: 'no-store' });\n                    if (data && data.ok && Array.isArray(data.items)) {\n                        homeFavoritesCache = data.items;\n                        homeFavoritesLoadedAt = now;\n                    }\n                } catch (e) {\n                    const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n                    if (out) out.textContent = `❌ 收藏列表加载失败：${e.message || e}`;\n                }\n            }\n\n            if (!homeFavoritesCache.length) {\n                box.innerHTML = `<div class=\"asset-sub\" style=\"padding:4px 2px;\">${t('homeFavEmpty')}</div>`;\n                return;\n            }\n\n            box.innerHTML = homeFavoritesCache.map((it) => {\n                const id = (it.id || '').replace(/'/g, \"\\\\'\");\n                const thumb = it.thumb_url || it.url || '';\n                const time = it.created_at || '';\n                return `<div class=\"home-fav-item\">\n                    <img src=\"${thumb}\" loading=\"lazy\" alt=\"favorite-home\" />\n                    <div class=\"home-fav-meta\">${time}</div>\n                    <button onclick=\"applyHomeFavorite('${id}')\">${t('homeFavApply')}</button>\n                    <button class=\"home-fav-del\" onclick=\"deleteHomeFavorite('${id}')\">${t('homeFavDelete')}</button>\n                </div>`;\n            }).join('');\n        }\n\n        async function saveCurrentHomeFavorite() {\n            const btn = document.getElementById('btn-favorite-home');\n            flashButtonActive(btn);\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            try {\n                const data = await fetchJsonSafe('/assets/home-favorites/save-current', { method: 'POST' });\n                if (!data.ok) {\n                    out.textContent = `❌ 收藏失败：${data.msg || 'unknown error'}`;\n                    return;\n                }\n                out.textContent = t('homeFavSaved');\n                await renderHomeFavorites(true);\n            } catch (e) {\n                out.textContent = `❌ 收藏失败：${e.message || e}`;\n            }\n        }\n\n        async function applyHomeFavorite(id) {\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            if (!id) return;\n            showRoomLoadingOverlay();\n            setWorkingStatus('正在替换收藏地图');\n            try {\n                const data = await fetchJsonSafe('/assets/home-favorites/apply', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ id })\n                });\n                if (!data.ok) {\n                    out.textContent = `❌ 替换失败：${data.msg || 'unknown error'}`;\n                    return;\n                }\n                const ok = await refreshOfficeBackgroundOnly();\n                out.textContent = ok ? t('homeFavApplied') : `${t('homeFavApplied')}（局部刷新失败，可手动刷新页面）`;\n                try { setState('idle', '已应用收藏地图'); } catch (e) {}\n            } catch (e) {\n                out.textContent = `❌ 替换失败：${e.message || e}`;\n            } finally {\n                hideRoomLoadingOverlay();\n            }\n        }\n\n        async function deleteHomeFavorite(id) {\n            const out = document.getElementById('asset-move-result') || document.getElementById('asset-upload-result');\n            if (!id) return;\n            if (!window.confirm('确定删除这个收藏吗？删除后不可恢复。')) return;\n            try {\n                const data = await fetchJsonSafe('/assets/home-favorites/delete', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ id })\n                });\n                if (!data.ok) {\n                    out.textContent = `❌ 删除失败：${data.msg || 'unknown error'}`;\n                    return;\n                }\n                out.textContent = t('homeFavDeleted');\n                await renderHomeFavorites(true);\n            } catch (e) {\n                out.textContent = `❌ 删除失败：${e.message || e}`;\n            }\n        }\n\n        async function resetSelectedAssetToDefault() {\n            const out = document.getElementById('asset-upload-result');\n            const path = selectedAssetInfo && selectedAssetInfo.path;\n            if (!path) {\n                if (out) out.textContent = '请先选择一个资产';\n                return;\n            }\n            if (!window.confirm(`⚠️ 确定将 ${path} 重置为默认资产吗？`)) return;\n            try {\n                const res = await fetch('/assets/restore-default', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ path })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (out) out.textContent = `❌ 重置失败：${data.msg || res.status}`;\n                    return;\n                }\n                await refreshSceneObjectByAssetPath(path);\n                if (out) out.textContent = `✅ 已重置为默认资产：${path}`;\n            } catch (e) {\n                if (out) out.textContent = `❌ 重置失败：${e}`;\n            }\n        }\n\n        async function restoreSelectedAssetPrev() {\n            const out = document.getElementById('asset-upload-result');\n            const path = selectedAssetInfo && selectedAssetInfo.path;\n            if (!path) {\n                if (out) out.textContent = '请先选择一个资产';\n                return;\n            }\n            if (!window.confirm(`⚠️ 确定将 ${path} 回退到上一版吗？`)) return;\n            try {\n                const res = await fetch('/assets/restore-prev', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ path })\n                });\n                const data = await res.json();\n                if (!data.ok) {\n                    if (out) out.textContent = `❌ 回退失败：${data.msg || res.status}`;\n                    return;\n                }\n                await refreshSceneObjectByAssetPath(path);\n                if (out) out.textContent = `✅ 已回退到上一版：${path}`;\n            } catch (e) {\n                if (out) out.textContent = `❌ 回退失败：${e}`;\n            }\n        }\n\n        // 记录 body scroll 位置，drawer 关闭时恢复\n        let _drawerScrollY = 0;\n\n        async function toggleAssetDrawer(force) {\n            const drawer = document.getElementById('asset-drawer');\n            const backdrop = document.getElementById('asset-drawer-backdrop');\n            const next = (typeof force === 'boolean') ? force : !assetDrawerOpen;\n            assetDrawerOpen = next;\n            drawer.classList.toggle('open', next);\n            if (backdrop) backdrop.classList.toggle('open', next);\n            // 移动端 body 锁定：打开时冻结滚动位置，关闭时恢复\n            if (next) {\n                _drawerScrollY = window.scrollY;\n                document.body.style.top = `-${_drawerScrollY}px`;\n            }\n            document.body.classList.toggle('drawer-open', next);\n            if (!next) {\n                document.body.style.top = '';\n                window.scrollTo(0, _drawerScrollY);\n            }\n\n            const openBtn = document.getElementById('btn-open-drawer');\n            if (openBtn) {\n                openBtn.classList.toggle('is-active', next);\n                openBtn.textContent = t('btnDecor');\n            }\n            const closeBtn = document.getElementById('btn-close-drawer');\n            if (closeBtn) closeBtn.textContent = t('drawerClose');\n            if (next) {\n                assetManualPanelOpen = false;\n                updateAssetAuthUI();\n                bindAssetDrawerBackgroundDeselect();\n                await ensureGeminiConfigLoaded();\n                if (assetDrawerAuthed) {\n                    await applySavedPositionOverrides();\n                    await refreshAssetDrawerList();\n                    await renderHomeFavorites(false);\n                    bindDrawerFileMeta();\n                } else {\n                    const msg = document.getElementById('asset-auth-msg');\n                    if (msg) msg.textContent = t('authDefaultPassHint');\n                }\n            } else {\n                assetManualPanelOpen = false;\n                updateManualPanelUI();\n                clearAssetSelectionUI();\n            }\n        }\n\n        // Guest Agent 离开房间\n        function removeGuestSpriteByName(name) {\n            const target = guestAgents.find(a => (a.name || '') === name);\n            if (target && guestSprites[target.agentId]) {\n                guestSprites[target.agentId].sprite.destroy();\n                guestSprites[target.agentId].nameText.destroy();\n                delete guestSprites[target.agentId];\n            }\n            if (target && guestBubbles[target.agentId]) {\n                guestBubbles[target.agentId].destroy();\n                delete guestBubbles[target.agentId];\n            }\n        }\n\n        function leaveGuestAgent(agentId, name) {\n            fetch('/leave-agent', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ agentId, name })\n            }).then(response => response.json()).then(data => {\n                if (data.ok) {\n                    // 优先按 agentId 清理，避免重名误伤\n                    if (agentId && guestSprites[agentId]) {\n                        guestSprites[agentId].sprite.destroy();\n                        guestSprites[agentId].nameText.destroy();\n                        delete guestSprites[agentId];\n                    }\n                    if (agentId && guestBubbles[agentId]) {\n                        guestBubbles[agentId].destroy();\n                        delete guestBubbles[agentId];\n                    }\n                    fetchGuestAgents();\n                    alert((name || agentId) + ' 已离开房间');\n                } else {\n                    // demo agent 没在后端也允许本地隐藏\n                    if (DEMO_MODE && (name === '尼卡' || name === '水星')) {\n                        hiddenDemoNames.add(name);\n                        removeGuestSpriteByName(name);\n                        renderGuestAgentList();\n                        alert(name + ' 已离开房间（demo）');\n                        return;\n                    }\n                    alert('离开失败：' + (data.msg || '未知错误'));\n                }\n            }).catch(error => {\n                alert('请求失败：' + error);\n            });\n        }\n\n        function approveGuestAgent(agentId) {\n            fetch('/agent-approve', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ agentId })\n            }).then(response => response.json()).then(data => {\n                if (data.ok) {\n                    fetchGuestAgents();\n                    alert('已批准该访客接入');\n                } else {\n                    alert('批准失败：' + (data.msg || '未知错误'));\n                }\n            }).catch(error => {\n                alert('请求失败：' + error);\n            });\n        }\n\n        function rejectGuestAgent(agentId) {\n            fetch('/agent-reject', {\n                method: 'POST',\n                headers: { 'Content-Type': 'application/json' },\n                body: JSON.stringify({ agentId })\n            }).then(response => response.json()).then(data => {\n                if (data.ok) {\n                    fetchGuestAgents();\n                    alert('已拒绝该访客');\n                } else {\n                    alert('拒绝失败：' + (data.msg || '未知错误'));\n                }\n            }).catch(error => {\n                alert('请求失败：' + error);\n            });\n        }\n\n        function ensureDemoVisitors() {\n            if (!DEMO_MODE) return;\n            if (!Array.isArray(window.__demoVisitors) || window.__demoVisitors.length === 0) {\n                window.__demoVisitors = [\n                    { agentId: 'demo_nika', name: '尼卡', authStatus: 'approved', state: 'writing', bubbleText: '我在工作中', isDemo: true, updated_at: new Date().toISOString() },\n                    { agentId: 'demo_mercury', name: '水星', authStatus: 'approved', state: 'idle', bubbleText: '我去休息区躺一下', isDemo: true, updated_at: new Date().toISOString() }\n                ];\n            }\n        }\n\n        function getMergedVisitors() {\n            const realVisitors = (guestAgents || []).filter(a => !a.isMain);\n            if (!DEMO_MODE) return realVisitors;\n\n            ensureDemoVisitors();\n            const demoVisitors = window.__demoVisitors.filter(v => !hiddenDemoNames.has(v.name));\n            return [...realVisitors, ...demoVisitors];\n        }\n\n        function renderGuestAgentList() {\n            const list = document.getElementById('guest-agent-list');\n            if (!list) return;\n\n            const visitors = getMergedVisitors();\n            if (visitors.length === 0) {\n                list.innerHTML = '<div style=\"color:#9ca3af;font-size:12px;text-align:center;padding:20px 0;\">暂无访客</div>';\n                return;\n            }\n\n            list.innerHTML = visitors.map(agent => {\n                const name = agent.name || '未命名访客';\n                const authStatus = agent.authStatus || 'pending';\n                const state = agent.state || 'idle';\n                const statusMap = {\n                    approved: '已授权',\n                    pending: '待授权',\n                    rejected: '已拒绝',\n                    offline: '离线'\n                };\n                const stateMap = {\n                    idle: '待命',\n                    writing: '工作',\n                    researching: '调研',\n                    executing: '执行',\n                    syncing: '同步',\n                    error: '报警'\n                };\n\n                const statusText = statusMap[authStatus] || authStatus;\n                const stateText = stateMap[state] || state;\n                const subtitle = `${statusText} · ${stateText}`;\n\n                const pendingActions = `<button onclick=\"alert('交换 skill 功能开发中')\">交换skill</button><button class=\"leave-btn\" onclick=\"leaveGuestAgent('${agent.agentId}','${name}')\">离开房间</button>`;\n\n                return `\n                  <div class=\"guest-agent-item\" data-name=\"${name}\">\n                    <div>\n                      <div class=\"guest-agent-name\">${name}</div>\n                      <div style=\"font-size:11px;color:#cbd5e1;\">${subtitle}</div>\n                    </div>\n                    <div class=\"guest-agent-buttons\">\n                      ${pendingActions}\n                    </div>\n                  </div>\n                `;\n            }).join('');\n        }\n\n        function getAreaRect(area) {\n            // 区域坐标（海辛提供，左上-右下；这里的 x/y 作为 sprite 底部锚点坐标来用）\n            // 休息区域范围（511,262）（841,621）\n            // 工作区域范围（190,526）（380,683）\n            // error 区域范围（932,275）（1109,327）\n            const rects = {\n                breakroom: { x1: 511, y1: 262, x2: 841, y2: 621 },\n                writing:   { x1: 190, y1: 526, x2: 380, y2: 683 },\n                error:     { x1: 932, y1: 275, x2: 1109, y2: 327 }\n            };\n            return rects[area] || rects.breakroom;\n        }\n\n        function randomInt(min, max) {\n            return Math.floor(Math.random() * (max - min + 1)) + min;\n        }\n\n        function randomPointInRect(rect) {\n            return { x: randomInt(rect.x1, rect.x2), y: randomInt(rect.y1, rect.y2) };\n        }\n\n        function getAreaPoint(area, idx) {\n            // 非 demo 访客：仍用固定点位，避免每次轮询都抖动。\n            const map = {\n                breakroom: [\n                    { x: 511, y: 262 },\n                    { x: 841, y: 621 },\n                    { x: 690, y: 470 },\n                    { x: 600, y: 340 },\n                    { x: 770, y: 540 },\n                    { x: 550, y: 420 },\n                    { x: 720, y: 310 },\n                    { x: 650, y: 580 }\n                ],\n                writing: [\n                    { x: 190, y: 526 },\n                    { x: 380, y: 683 },\n                    { x: 300, y: 610 },\n                    { x: 240, y: 570 },\n                    { x: 350, y: 640 },\n                    { x: 160, y: 600 },\n                    { x: 420, y: 560 },\n                    { x: 280, y: 660 }\n                ],\n                error: [\n                    { x: 932, y: 275 },\n                    { x: 1109, y: 327 },\n                    { x: 1020, y: 305 },\n                    { x: 960, y: 340 },\n                    { x: 1070, y: 280 },\n                    { x: 990, y: 260 },\n                    { x: 1050, y: 350 },\n                    { x: 940, y: 310 }\n                ]\n            };\n            const arr = map[area] || map.breakroom;\n            return arr[idx % arr.length];\n        }\n\n        function renderGuestAgentsInScene() {\n            if (!game) return;\n            const visitors = getMergedVisitors();\n            const seenIds = new Set();\n            let idxBreak = 0, idxWrite = 0, idxError = 0;\n\n            visitors.forEach(agent => {\n                const id = agent.agentId;\n                seenIds.add(id);\n\n                const isDemo = !!agent.isDemo || (DEMO_MODE && (id === 'demo_nika' || id === 'demo_mercury' || agent.name === '尼卡' || agent.name === '水星'));\n                const area = agent.area || (agent.state === 'error' ? 'error' : (agent.state === 'idle' ? 'breakroom' : 'writing'));\n\n                const idx = area === 'breakroom' ? idxBreak++ : area === 'error' ? idxError++ : idxWrite++;\n                const p = isDemo\n                    ? randomPointInRect(getAreaRect(area))\n                    : getAreaPoint(area, idx);\n\n                if (!guestSprites[id]) {\n                    // 优先用图标：demo visitor 有专门映射\n                    let sprite;\n                    const isDemoNika = DEMO_MODE && (agent.agentId === 'demo_nika' || agent.name === '尼卡');\n                    const isDemoMercury = DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星');\n                    \n                    if (isDemoNika || isDemoMercury) {\n                        // 统一使用动态像素角色，避免依赖已删除的 demo 静态图\n                        const animKey = 'guest_anim_1';\n                        const f = 0;\n                        sprite = game.add.sprite(p.x, p.y, animKey, f).setOrigin(0.5, 1).setScale(1.1);\n                        if (sprite.anims && sprite.anims.play) sprite.anims.play(animKey, true);\n                    } else {\n                        // 非 demo 访客：优先用动画精灵（guest_anim_x），其次静态图，兜底星星\n                        // 先确定角色索引（1-6）\n                        let animIdx = agent.avatar\n                            ? parseInt((agent.avatar.match(/_(\\d+)$/) || [])[1] || '0', 10)\n                            : 0;\n                        if (!animIdx || animIdx < 1 || animIdx > 6) {\n                            const aid = String(agent.agentId || '');\n                            let hash = 0;\n                            for (let i = 0; i < aid.length; i++) hash = (hash * 31 + aid.charCodeAt(i)) >>> 0;\n                            animIdx = (hash % 6) + 1;\n                        }\n                        const animKey = `guest_anim_${animIdx}`;\n                        const animIdleKey = `guest_anim_${animIdx}_idle`;\n\n                        if (game.textures.exists(animKey) && game.anims.exists(animIdleKey)) {\n                            sprite = game.add.sprite(p.x, p.y, animKey).setOrigin(0.5, 1).setScale(4.0);\n                            sprite.anims.play(animIdleKey, true);\n                        } else {\n                            const staticAvatarKey = agent.avatar && game.textures.exists(agent.avatar)\n                                ? agent.avatar\n                                : (() => {\n                                    const aid = String(agent.agentId || '');\n                                    let hash = 0;\n                                    for (let i = 0; i < aid.length; i++) hash = (hash * 31 + aid.charCodeAt(i)) >>> 0;\n                                    return GUEST_AVATARS[hash % GUEST_AVATARS.length];\n                                })();\n\n                            if (staticAvatarKey && game.textures.exists(staticAvatarKey)) {\n                                sprite = game.add.image(p.x, p.y, staticAvatarKey).setOrigin(0.5, 1).setScale(1.15);\n                            } else {\n                                sprite = game.add.text(p.x, p.y, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '30px' }).setOrigin(0.5, 1);\n                            }\n                        }\n                    }\n                    sprite.setDepth(2600);\n                    if (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) {\n                        sprite.y = sprite.y + 10;\n                    }\n\n                    // demo 水星下移 10px（仅 demo_mercury）\n                    const yOffset = (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) ? 10 : 0;\n\n                    const nameTextY = isDemo ? ((p.y + yOffset) - 80) : ((p.y + yOffset) - 120);\n                    const nameText = game.add.text(p.x, nameTextY, agent.name || '访客', {\n                        fontFamily: 'ArkPixel, monospace',\n                        fontSize: isDemo ? '16px' : '15px',\n                        fill: '#ffffff',\n                        stroke: '#000',\n                        strokeThickness: 3\n                    }).setOrigin(0.5);\n                    nameText.setDepth(2601);\n\n                    guestSprites[id] = { sprite, nameText };\n                } else {\n                    const g = guestSprites[id];\n                    const yOffset = (DEMO_MODE && (agent.agentId === 'demo_mercury' || agent.name === '水星')) ? 10 : 0;\n\n                    // demo：平滑移动（避免闪现）；非 demo：保持稳定位置（避免轮询抖动）\n                    if (isDemo) {\n                        // kill previous tweens for this id\n                        if (guestTweens[id] && guestTweens[id].move) {\n                            guestTweens[id].move.stop();\n                        }\n                        if (guestTweens[id] && guestTweens[id].name) {\n                            guestTweens[id].name.stop();\n                        }\n\n                        const duration = 2000 + Math.floor(Math.random() * 1000); // 2~3s 走路感\n                        const ease = 'Sine.easeInOut';\n\n                        const moveTween = game.tweens.add({\n                            targets: g.sprite,\n                            x: p.x,\n                            y: p.y + yOffset,\n                            duration,\n                            ease\n                        });\n                        const nameTween = game.tweens.add({\n                            targets: g.nameText,\n                            x: p.x,\n                            y: (p.y + yOffset) - 80,\n                            duration,\n                            ease\n                        });\n                        guestTweens[id] = { move: moveTween, name: nameTween };\n                    } else {\n                        g.sprite.x = p.x;\n                        g.sprite.y = p.y + yOffset;\n                        g.nameText.x = p.x;\n                        g.nameText.y = (p.y + yOffset) - 120;\n                    }\n\n                    g.nameText.setText(agent.name || '访客');\n                }\n            });\n\n            // 删除消失的 agent + 清理其气泡/tween\n            Object.keys(guestSprites).forEach(id => {\n                if (!seenIds.has(id)) {\n                    guestSprites[id].sprite.destroy();\n                    guestSprites[id].nameText.destroy();\n                    delete guestSprites[id];\n                    if (guestBubbles[id]) {\n                        guestBubbles[id].destroy();\n                        delete guestBubbles[id];\n                    }\n                    if (guestTweens[id]) {\n                        try { guestTweens[id].move && guestTweens[id].move.stop(); } catch(e) {}\n                        try { guestTweens[id].name && guestTweens[id].name.stop(); } catch(e) {}\n                        delete guestTweens[id];\n                    }\n                }\n            });\n        }\n\n        function maybeShowGuestBubble(time) {\n            if (time - lastGuestBubbleAt < 5200) return;\n            lastGuestBubbleAt = time;\n            const ids = Object.keys(guestSprites);\n            if (ids.length === 0) return;\n            const id = ids[Math.floor(Math.random() * ids.length)];\n            const g = guestSprites[id];\n\n            // demo 气泡：优先展示与状态对应的内容，便于验证“状态→区域→气泡”链路\n            const demoVisitor = (DEMO_MODE && window.__demoVisitors)\n                ? (window.__demoVisitors.find(v => v.agentId === id) || window.__demoVisitors.find(v => v.name === (g.nameText && g.nameText.text)))\n                : null;\n\n            const statusThoughtsMap = {\n                idle: ['我在休息区待命', '先放松一下，等下一步任务', '我在休息充电中'],\n                writing: ['我在工作区处理任务', '正在整理文档与执行中', '工作区专注推进中'],\n                researching: ['我在调研模式，搜集信息', '正在查资料和验证线索', '研究中，稍后同步结论'],\n                executing: ['执行中，正在跑流程', '我在工作区推进任务', '正在把计划落地执行'],\n                syncing: ['同步中，马上更新状态', '正在同步进度到系统', '数据同步中请稍候'],\n                error: ['我在 bug 区排查问题', '检测到异常，正在修复', '报警中，先定位再处理']\n            };\n            const agentState = (guestAgents.find(a => a.agentId === id) || {}).state || 'idle';\n            const thoughts = statusThoughtsMap[agentState] || statusThoughtsMap.idle;\n            const text = (demoVisitor && demoVisitor.bubbleText) ? demoVisitor.bubbleText : thoughts[Math.floor(Math.random() * thoughts.length)];\n\n            if (guestBubbles[id]) {\n                guestBubbles[id].destroy();\n                delete guestBubbles[id];\n            }\n\n            const bx = g.sprite.x;\n            // 气泡位置：demo 维持原逻辑；真实访客放在“名字上方”，避免压角色也避免压名字\n            const isDemoGuest = (demoVisitor && demoVisitor.isDemo) || (id === 'demo_nika' || id === 'demo_mercury');\n            const nameH = (g.nameText && g.nameText.height) ? g.nameText.height : 16;\n            const by = isDemoGuest ? (g.sprite.y - 90) : ((g.nameText ? g.nameText.y : (g.sprite.y - 150)) - (nameH / 2) - 22);\n            const fontSize = IS_TOUCH_DEVICE ? 14 : 12;\n            const bg = game.add.rectangle(bx, by, text.length * 10 + 24, 28, 0xffffff, 0.95);\n            bg.setStrokeStyle(2, 0x000000);\n            const txt = game.add.text(bx, by, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000' }).setOrigin(0.5);\n            const bubble = game.add.container(0, 0, [bg, txt]);\n            bubble.setDepth(2700);\n            guestBubbles[id] = bubble;\n\n            // 让气泡跟随 sprite 锚点（用于 demo 平滑移动时也保持贴合）\n            bubble.__followAgentId = id;\n\n            setTimeout(() => {\n                if (guestBubbles[id]) {\n                    guestBubbles[id].destroy();\n                    delete guestBubbles[id];\n                }\n            }, 3200);\n        }\n\n        function maybeRandomizeDemoVisitors() {\n            if (!DEMO_MODE) return;\n            ensureDemoVisitors();\n\n            // 按海辛需求：每 8 秒切换一次状态\n            window.__demoNextAt = window.__demoNextAt || 0;\n            const now = Date.now();\n            if (now < window.__demoNextAt) return;\n            window.__demoNextAt = now + 8000;\n\n            const states = ['idle', 'writing', 'researching', 'executing', 'syncing', 'error'];\n            const bubbleTextMapByLang = {\n                zh: {\n                idle: '我去休息区躺一下',\n                writing: '我在工作中',\n                researching: '我在调研中',\n                executing: '我在执行任务',\n                syncing: '我在同步状态',\n                error: '出错了！我去报警区'\n                },\n                en: {\n                    idle: 'Taking a break in the lounge.',\n                    writing: 'I am working now.',\n                    researching: 'I am researching now.',\n                    executing: 'I am executing tasks.',\n                    syncing: 'I am syncing status.',\n                    error: 'Something broke! Heading to alert zone.'\n                },\n                ja: {\n                    idle: '休憩エリアでひと休み。',\n                    writing: '作業中です。',\n                    researching: '調査中です。',\n                    executing: 'タスクを実行中です。',\n                    syncing: '状態を同期中です。',\n                    error: 'エラー発生！アラートエリアへ。'\n                }\n            };\n            const bubbleTextMap = bubbleTextMapByLang[uiLang] || bubbleTextMapByLang.zh;\n\n            // 确保两位 demo 角色不会总是同一个状态（增加可观测性）\n            const pickJs = (exclude) => {\n                let s = states[Math.floor(Math.random() * states.length)];\n                let tries = 0;\n                while (exclude && s === exclude && tries < 5) {\n                    s = states[Math.floor(Math.random() * states.length)];\n                    tries++;\n                }\n                return s;\n            };\n\n            const current = window.__demoVisitors || [];\n            const cur0 = current[0] ? (current[0].state || 'idle') : 'idle';\n\n            const next0 = pickJs(cur0);\n            const next1 = pickJs(next0); // 尽量不同\n            const nextStates = [next0, next1];\n\n            const prevVisitors = current.map((v) => ({ ...v }));\n            window.__demoVisitors = current.map((v, i) => {\n                const nextState = nextStates[i] || pickJs(v.state);\n                return {\n                    ...v,\n                    state: nextState,\n                    bubbleText: bubbleTextMap[nextState] || String(nextState),\n                    updated_at: new Date().toISOString()\n                };\n            });\n\n            // 状态切换时：每一位 demo 都立即冒泡（强制），用于清晰验证链路\n            try {\n                if (typeof game !== 'undefined' && game) {\n                    // 找出状态实际变了的 demo visitor，给他们强制冒泡\n                    const prevById = {};\n                    prevVisitors.forEach(v => { prevById[v.agentId] = v; });\n                    const newVisitors = window.__demoVisitors || [];\n                    newVisitors.forEach(agent => {\n                        const prev = prevById[agent.agentId];\n                        const changed = !prev || prev.state !== agent.state;\n                        if (changed) {\n                            // 直接冒泡\n                            if (guestSprites[agent.agentId]) {\n                                const g = guestSprites[agent.agentId];\n                                const text = agent.bubbleText || '';\n                                if (guestBubbles[agent.agentId]) {\n                                    guestBubbles[agent.agentId].destroy();\n                                    delete guestBubbles[agent.agentId];\n                                }\n                                const bx = g.sprite.x;\n                                const by = g.sprite.y - 90;\n                                const fontSize = IS_TOUCH_DEVICE ? 14 : 12;\n                                const bg = game.add.rectangle(bx, by, text.length * 10 + 24, 28, 0xffffff, 0.95);\n                                bg.setStrokeStyle(2, 0x000000);\n                                const txt = game.add.text(bx, by, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000' }).setOrigin(0.5);\n                                const bubble = game.add.container(0, 0, [bg, txt]);\n                                bubble.setDepth(2700);\n                                bubble.__followAgentId = agent.agentId;\n                                guestBubbles[agent.agentId] = bubble;\n                                setTimeout(() => {\n                                    if (guestBubbles[agent.agentId]) {\n                                        guestBubbles[agent.agentId].destroy();\n                                        delete guestBubbles[agent.agentId];\n                                    }\n                                }, 3200);\n                            }\n                        }\n                    });\n                }\n            } catch (e) { console.error('强制冒泡失败:', e); }\n        }\n\n        function fetchGuestAgents() {\n            // demo 随机状态先更新（不依赖后端）\n            maybeRandomizeDemoVisitors();\n\n            return fetch('/agents?t=' + Date.now(), { cache: 'no-store' })\n                .then(response => response.json())\n                .then(data => {\n                    // 无论后端返回是否为数组，demo=1 都应保证本地 demo 访客可见\n                    guestAgents = Array.isArray(data) ? data : [];\n\n                    // 新访客检测：触发 Star 欢迎气泡（只欢迎真实访客，不欢迎 demo）\n                    try {\n                        const merged = getMergedVisitors();\n                        const currentIds = new Set((merged || []).filter(a => !a.isMain && !a.isDemo).map(a => a.agentId));\n\n                        if (!guestWelcomeInitialized) {\n                            // 首次初始化不欢迎，避免刷新页面就刷屏\n                            lastSeenGuestIds = currentIds;\n                            guestWelcomeInitialized = true;\n                        } else {\n                            const newIds = [];\n                            currentIds.forEach(id => { if (!lastSeenGuestIds.has(id)) newIds.push(id); });\n\n                            if (newIds.length > 0) {\n                                // 只欢迎第一个新来的（避免同一时刻多人加入刷屏）\n                                const newAgent = (merged || []).find(a => a.agentId === newIds[0]);\n                                if (newAgent && newAgent.name) {\n                                    // 临时将 currentState 视为 writing 以允许 showBubble 展示\n                                    const oldState = currentState;\n                                    currentState = 'writing';\n                                    // 临时更换 bubble 文案\n                                    const lang = uiLang;\n                                    const welcomeTexts = {\n                                        zh: [`欢迎 ${newAgent.name} 来到办公室～`,`Hi ${newAgent.name}，一起开工吧`,`${newAgent.name} 已加入，欢迎！`],\n                                        en: [`Welcome ${newAgent.name} to the office!`,`Hi ${newAgent.name}, let’s build something.`,`${newAgent.name} just joined — welcome!`],\n                                        ja: [`${newAgent.name} さん、オフィスへようこそ！`,`Hi ${newAgent.name}、一緒に進めよう。`,`${newAgent.name} さんが参加しました、歓迎！`]\n                                    };\n                                    const langPack = BUBBLE_TEXTS[lang] || BUBBLE_TEXTS.zh;\n                                    const oldTexts = Array.isArray(langPack.writing) ? [...langPack.writing] : [];\n                                    langPack.writing = welcomeTexts[lang] || welcomeTexts.zh;\n                                    showBubble();\n                                    // 还原\n                                    langPack.writing = oldTexts;\n                                    currentState = oldState;\n                                }\n                            }\n\n                            lastSeenGuestIds = currentIds;\n                        }\n                    } catch (e) { /* ignore */ }\n\n                    renderGuestAgentList();\n                    renderGuestAgentsInScene();\n                })\n                .catch(error => {\n                    console.error('拉取访客列表失败:', error);\n                    // 即使拉取失败，demo 也要能渲染\n                    if (DEMO_MODE) {\n                        renderGuestAgentList();\n                        renderGuestAgentsInScene();\n                    }\n                });\n        }\n\n        // 初始化：先检测 WebP 支持，再启动游戏\n        async function initGame() {\n            // 检测 WebP 支持\n            try {\n                supportsWebP = await checkWebPSupport();\n            } catch (e) {\n                try {\n                    supportsWebP = await checkWebPSupportFallback();\n                } catch (e2) {\n                    supportsWebP = false;\n                }\n            }\n\n            console.log('WebP 支持:', supportsWebP);\n            applyLanguage();\n            updateSpeedModeUI();\n\n\n            // 直接启动 Phaser，避免首屏被额外接口阻塞\n\n            new Phaser.Game(config);\n\n            // 非关键初始化延后到首屏之后，提升首开速度\n            setTimeout(async () => {\n                // 动态探测 flowers 精灵表帧规格（避免写死 65x65 导致显示比例异常）\n                try {\n                    const res = await fetch('/assets/list?t=' + Date.now(), { cache: 'no-store' });\n                    const data = await res.json();\n                    if (data && data.ok && Array.isArray(data.items)) {\n                        const flowerItem = data.items.find(it => (it.path || '').toLowerCase().includes('flowers-bloom-v2'));\n                        if (flowerItem && Number(flowerItem.width) > 0 && Number(flowerItem.height) > 0) {\n                            // 固定规则：花朵单帧 128x128，4x4\n                            FLOWERS_FRAME_W = 128;\n                            FLOWERS_FRAME_H = 128;\n                            FLOWERS_FRAME_COLS = 4;\n                            FLOWERS_FRAME_ROWS = 4;\n                        }\n                    }\n                } catch (e) {\n                    console.warn('flowers 规格探测失败，使用默认 65x65', e);\n                }\n\n                // Only fetch /assets/positions and /assets/defaults when user is authed,\n                // to avoid 401 Unauthorized on first load for new visitors (issue #54).\n                try {\n                    const authRes = await fetch('/assets/auth/status', { cache: 'no-store' });\n                    const authData = await authRes.json();\n                    if (authData && authData.ok && authData.authed) {\n                        await applySavedPositionOverrides();\n                    }\n                } catch (e) {}\n            }, 600);\n        }\n\n        function preload() {\n            // 获取加载界面元素\n            loadingOverlay = document.getElementById('loading-overlay');\n            loadingProgressBar = document.getElementById('loading-progress-bar');\n            loadingText = document.getElementById('loading-text');\n            loadingProgressContainer = document.getElementById('loading-progress-container');\n            \n            // 设置资源总数（全部首屏加载：装饰也第一时间出现）\n            totalAssets = 15;\n            loadedAssets = 0;\n            \n            // 加载进度监听\n            this.load.on('filecomplete', () => {\n                updateLoadingProgress();\n            });\n            \n            this.load.on('complete', () => {\n                hideLoadingOverlay();\n            });\n\n            // cache-busting to avoid stale background on client/CDN\n            // use smaller/new map version provided by user\n            this.load.image('office_bg', '/static/office_bg_small.webp?v={{VERSION_TIMESTAMP}}');\n            this.load.spritesheet('star_idle', '/static/star-idle-v5.png?v={{VERSION_TIMESTAMP}}', { frameWidth: 256, frameHeight: 256 });\n\n            // Furniture\n            this.load.image('sofa_idle', '/static/sofa-idle-v3.png?v={{VERSION_TIMESTAMP}}');\n            this.load.image('sofa_shadow', '/static/sofa-shadow-v1.png?v={{VERSION_TIMESTAMP}}');\n\n            // Decor\n            this.load.spritesheet('plants', '/static/plants-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });\n            this.load.spritesheet('posters', '/static/posters-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });\n            this.load.spritesheet('coffee_machine', '/static/coffee-machine-v3-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 230, frameHeight: 230 });\n            this.load.image('coffee_machine_shadow', '/static/coffee-machine-shadow-v1.png?v={{VERSION_TIMESTAMP}}');\n            this.load.spritesheet('serverroom', '/static/serverroom-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 180, frameHeight: 251 });\n\n            // Error / bug animation: 180x180, 96 frames (repacked grid)\n            this.load.spritesheet('error_bug', '/static/error-bug-spritesheet-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 220, frameHeight: 220 });\n\n            // 运行时 Gemini 配置（用于搬家/中介生图）\n            this.geminiConfig = { hasKey: false, model: 'gemini-3.1-flash-image-preview' };\n\n            // Cat spritesheet: 160x160, 4x4=16 cats\n            this.load.spritesheet('cats', '/static/cats-spritesheet.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 160, frameHeight: 160 });\n\n            // Desk\n            // Star working animation: repacked to grid to avoid WebGL max texture size limits\n            // NOTE: prefer WebP for size, PNG fallback\n            // 动态替换后按最新素材识别：当前 writing 素材为 300x300 单帧\n            this.load.spritesheet('star_working', '/static/star-working-spritesheet-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 300, frameHeight: 300 });\n\n            // Sync state animation (256x256, 多帧): 非同步显示首帧，同步从第2帧循环\n            this.load.spritesheet('sync_anim', '/static/sync-animation-v3-grid.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 256, frameHeight: 256 });\n\n            // Memo background image\n            // memo 底图固定走 png，避免某些端 webp 透明通道异常导致“底图丢失”\n            this.load.image('memo_bg', '/static/memo-bg.webp?v={{VERSION_TIMESTAMP}}');\n            // Desk v2 (webp only)\n            this.load.image('desk_v2', '/static/desk-v3.webp?v={{VERSION_TIMESTAMP}}');\n            // Flower spritesheet (65x65, 16 frames)\n            this.load.spritesheet('flowers', '/static/flowers-bloom-v2.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: FLOWERS_FRAME_W, frameHeight: FLOWERS_FRAME_H });\n\n            // Guest/Demo agent sprites\n            this.load.spritesheet('guest_anim_1', '/static/guest_anim_1.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_2', '/static/guest_anim_2.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_3', '/static/guest_anim_3.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_4', '/static/guest_anim_4.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_5', '/static/guest_anim_5.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n            this.load.spritesheet('guest_anim_6', '/static/guest_anim_6.webp?v={{VERSION_TIMESTAMP}}', { frameWidth: 32, frameHeight: 32 });\n        }\n\n        function create() {\n            game = this;\n            hideGameSkeleton();\n            officeBgSprite = this.add.image(640, 360, 'office_bg');\n\n            // Place furniture: Sofa\n            // NOTE: coordinates are interpreted as the TOP-LEFT corner of the sprite\n            const sofaShadow = this.add.image(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y, 'sofa_shadow').setOrigin(0.5);\n            sofaShadow.setDepth(9);\n            sofa = this.add.sprite(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y, 'sofa_idle').setOrigin(0.5);\n            sofa.setDepth(10);\n            areas = {\n                door: { x: 640, y: 550 }, // 墙的门（偏下 1/3 位置）\n                writing: { x: 320, y: 360 }, // 左 1/3 中间（办公桌）\n                researching: { x: 320, y: 360 }, // 左 1/3 中间（研究也在办公区\n                error: { x: 1066, y: 180 }, // 右 1/3 上 1/2（服务器区\n                breakroom: { x: IDLE_SOFA_ANCHOR.x, y: IDLE_SOFA_ANCHOR.y } // 与 sofa-idle-v3 同中心锚点\n            };\n\n            // 创建 Star 角色待命动画（每次先移除旧定义，确保不复用历史动画）\n            const starIdleFrameMax = Math.max(0, (this.textures.get('star_idle')?.frameTotal || 1) - 1);\n            if (this.anims.exists('star_idle')) {\n                this.anims.remove('star_idle');\n            }\n            this.anims.create({\n                key: 'star_idle',\n                frames: this.anims.generateFrameNumbers('star_idle', { start: 0, end: starIdleFrameMax }),\n                frameRate: 12,\n                repeat: -1\n            });\n\n\n            // 创建 6 个访客角色的循环 idle 动画（8帧循环）\n            for (let i = 1; i <= 6; i++) {\n                this.anims.create({\n                    key: `guest_anim_${i}_idle`,\n                    frames: this.anims.generateFrameNumbers(`guest_anim_${i}`, { start: 0, end: 7 }),\n                    frameRate: 8,\n                    repeat: -1\n                });\n            }\n\n            star = game.physics.add.sprite(areas.breakroom.x, areas.breakroom.y, 'star_idle');\n            star.setOrigin(0.5);\n            star.setScale(IDLE_STAR_SCALE);\n            star.setAlpha(0.95);\n            star.setDepth(20); // Put Star on top of everything\n            // Default: idle shows Star idle animation\n            star.setVisible(true);\n            star.anims.play('star_idle', true);\n\n            // Sofa stays static when idle (no longer the main idle animation)\n            sofa.anims.stop();\n            sofa.setTexture('sofa_idle');\n\n            // 加像素风小牌匾：海辛小龙虾的办公室\n            const plaqueX = config.width / 2;\n            const plaqueY = config.height - 36;\n            const plaqueBg = game.add.rectangle(plaqueX, plaqueY, 420, 44, 0x5d4037);\n            plaqueBg.setStrokeStyle(3, 0x3e2723);\n            const plaqueText = game.add.text(plaqueX, plaqueY, t('officeTitle'), {\n                fontFamily: 'ArkPixel, monospace',\n                fontSize: '18px',\n                fill: '#ffd700',\n                fontWeight: '900',\n                fontStyle: 'bold',\n                stroke: '#000',\n                strokeThickness: 3,\n                wordWrap: { width: 400 },\n                align: 'center'\n            }).setOrigin(0.5);\n            // 牌匾两边加个小装饰（跟随牌匾居中）\n            game.add.text(plaqueX - 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);\n            game.add.text(plaqueX + 190, plaqueY, '⭐', { fontFamily: 'ArkPixel, monospace', fontSize: '20px' }).setOrigin(0.5);\n            window.officePlaqueText = plaqueText;\n\n            // Random plant at (565,178) (frame 0-15, 160x160 each)\n            const plantFrameCount = 16;\n            const randomPlantFrame = Math.floor(Math.random() * plantFrameCount);\n            const plant = game.add.sprite(565, 178, 'plants', randomPlantFrame).setOrigin(0.5);\n            plant.setDepth(5);\n            plant.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.plantSprite = plant;\n            window.plantFrameCount = plantFrameCount;\n            \n            plant.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.plantFrameCount);\n                window.plantSprite.setFrame(next);\n            });\n\n            // Random plant at (230,185) (frame 0-15, 160x160 each)\n            const plant2Frame = Math.floor(Math.random() * plantFrameCount);\n            const plant2 = game.add.sprite(230, 185, 'plants', plant2Frame).setOrigin(0.5);\n            plant2.setDepth(5);\n            plant2.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.plantSprite2 = plant2;\n            \n            plant2.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.plantFrameCount);\n                window.plantSprite2.setFrame(next);\n            });\n\n            // Random plant at (977,496) (frame 0-15, 160x160 each)\n            const plant3Frame = Math.floor(Math.random() * plantFrameCount);\n            const plant3 = game.add.sprite(977, 496, 'plants', plant3Frame).setOrigin(0.5);\n            plant3.setDepth(5);\n            plant3.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.plantSprite3 = plant3;\n            \n            plant3.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.plantFrameCount);\n                window.plantSprite3.setFrame(next);\n            });\n\n            // Random poster at (252,66) (random frame from spritesheet)\n            const postersFrameCount = (this.textures.get('posters')?.frameTotal || 1) - 1;\n            const randomPosterFrame = Math.floor(Math.random() * Math.max(1, postersFrameCount));\n            const poster = game.add.sprite(252, 66, 'posters', randomPosterFrame).setOrigin(0.5);\n            poster.setDepth(4);\n            poster.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.posterSprite = poster;\n            window.posterFrameCount = postersFrameCount;\n            \n            poster.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.posterFrameCount);\n                window.posterSprite.setFrame(next);\n            });\n\n            // Random cat at (94,557)\n            const catsFrameCount = (this.textures.get('cats')?.frameTotal || 1) - 1;\n            const randomCatFrame = Math.floor(Math.random() * Math.max(1, catsFrameCount));\n            const cat = game.add.sprite(94, 557, 'cats', randomCatFrame).setOrigin(0.5);\n            cat.setDepth(2000); // top layer\n            cat.setInteractive({ useHandCursor: true });\n            // Expose to global for click handler\n            window.catSprite = cat;\n            window.catsFrameCount = catsFrameCount;\n            cat.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.catsFrameCount);\n                window.catSprite.setFrame(next);\n            });\n\n            // Coffee machine at (659,397) - animated sprite + shadow\n            const coffeeMachineShadow = this.add.image(659, 397, 'coffee_machine_shadow').setOrigin(0.5);\n            coffeeMachineShadow.setDepth(98);\n            const coffeeFrameMax = Math.max(0, (this.textures.get('coffee_machine')?.frameTotal || 1) - 2);\n            if (this.anims.exists('coffee_machine')) {\n                this.anims.remove('coffee_machine');\n            }\n            this.anims.create({\n                key: 'coffee_machine',\n                frames: this.anims.generateFrameNumbers('coffee_machine', { start: 0, end: coffeeFrameMax }),\n                frameRate: 12.5,\n                repeat: -1\n            });\n            const coffeeMachine = this.add.sprite(659, 397, 'coffee_machine').setOrigin(0.5);\n            coffeeMachine.setDepth(99);\n            coffeeMachine.anims.play('coffee_machine', true);\n\n            // Server room animation\n            const serverFrameMax = Math.max(0, (this.textures.get('serverroom')?.frameTotal || 1) - 2);\n            this.anims.create({\n                key: 'serverroom_on',\n                frames: this.anims.generateFrameNumbers('serverroom', { start: 0, end: serverFrameMax }),\n                frameRate: 6,\n                repeat: -1\n            });\n            serverroom = this.add.sprite(1021, 142, 'serverroom', 0).setOrigin(0.5);\n            serverroom.setDepth(2);\n            // 默认 idle: 静止第0帧\n            serverroom.anims.stop();\n            serverroom.setFrame(0);\n\n            // Desk at (218,417) (v2)\n            const desk = this.add.image(218, 417, 'desk_v2').setOrigin(0.5);\n            desk.setDepth(1001);  // desk above starWorking\n\n            // Random flower pot at (310,390), default scale 0.8 (top layer)\n            const flowerFrameCount = Math.max(1, FLOWERS_FRAME_COLS * FLOWERS_FRAME_ROWS); // 动态帧数\n            const randomFlowerFrame = Math.floor(Math.random() * flowerFrameCount);\n            const flower = this.add.sprite(310, 390, 'flowers', randomFlowerFrame).setOrigin(0.5);\n            flower.setScale(0.8);\n            flower.setDepth(1100); // highest among desk/starWorking\n            flower.setInteractive({ useHandCursor: true });\n            window.flowerSprite = flower;\n            window.flowerFrameCount = flowerFrameCount;\n            flower.on('pointerdown', () => {\n                const next = Math.floor(Math.random() * window.flowerFrameCount);\n                window.flowerSprite.setFrame(next);\n            });\n\n            // Star working at desk (217,333)\n            this.anims.create({\n                key: 'star_working',\n                // 38 帧（0~37），避免沿用旧 192 帧导致疯狂闪烁\n                frames: this.anims.generateFrameNumbers('star_working', { start: 0, end: 37 }),\n                frameRate: 12,\n                repeat: -1\n            });\n\n            // Error / bug animation (96 frames)\n            this.anims.create({\n                key: 'error_bug',\n                frames: this.anims.generateFrameNumbers('error_bug', { start: 0, end: 71 }),\n                frameRate: 12,\n                repeat: -1\n            });\n\n            // Error bug character (moves between two points when state=error)\n            const errorBug = this.add.sprite(1007, 221, 'error_bug', 0).setOrigin(0.5);\n            errorBug.setDepth(50); // above serverroom, below desk/bubbles\n            errorBug.setVisible(false);\n            errorBug.setScale(0.9); // shrink 10%\n            errorBug.anims.play('error_bug', true);\n            window.errorBug = errorBug;\n            window.errorBugDir = 1; // 1 -> to right, -1 -> to left\n            const starWorking = this.add.sprite(217, 343, 'star_working', 0).setOrigin(0.5);\n            starWorking.setVisible(false);\n            starWorking.setScale(0.9);\n            starWorking.setDepth(900);  // starWorking under desk so desk partially covers it\n            // Store reference to starWorking for state logic\n            window.starWorking = starWorking;\n\n            // Sync animation sprite at (1157,592)\n            const syncFrameTotal = Number(this.textures.get('sync_anim')?.frameTotal || 0);\n            const syncFrameStart = 1;\n            const syncFrameEnd = Math.max(0, syncFrameTotal - 2);\n            // 仅在确实存在可播放帧（>=1）时才创建同步动画，避免单帧素材触发播放异常\n            syncAnimPlayable = syncFrameTotal >= 3 && syncFrameEnd >= syncFrameStart;\n            if (this.anims.exists('sync_anim')) {\n                this.anims.remove('sync_anim');\n            }\n            if (syncAnimPlayable) {\n            this.anims.create({\n                key: 'sync_anim',\n                    frames: this.anims.generateFrameNumbers('sync_anim', { start: syncFrameStart, end: syncFrameEnd }),\n                frameRate: 12,\n                repeat: -1\n            });\n            }\n            syncAnimSprite = this.add.sprite(1157, 592, 'sync_anim', 0).setOrigin(0.5);\n            syncAnimSprite.setDepth(40);\n            // default show first frame only\n            syncAnimSprite.anims.stop();\n            syncAnimSprite.setFrame(0);\n\n            // Debug: expose star sprite too (for path calibration / visuals)\n            window.starSprite = star;\n\n            statusText = document.getElementById('status-text');\n            placeOverlayAndStatusAtCanvasBottomLeft();\n            window.addEventListener('resize', placeOverlayAndStatusAtCanvasBottomLeft);\n            window.addEventListener('scroll', placeOverlayAndStatusAtCanvasBottomLeft, { passive: true });\n            coordsOverlay = document.getElementById('coords-overlay');\n            coordsDisplay = document.getElementById('coords-display');\n            coordsToggle = document.getElementById('coords-toggle');\n\n            // guest agent 将由 /agents 动态拉取并渲染到右侧访客列表\n            coordsToggle.addEventListener('click', () => {\n                showCoords = !showCoords;\n                coordsOverlay.style.display = showCoords ? 'block' : 'none';\n                coordsToggle.textContent = showCoords ? t('hideCoords') : t('showCoords');\n                coordsToggle.style.background = showCoords ? '#e94560' : '#333';\n            });\n\n            // 允许手机端“拖动/滑动”来移动视野（本质：移动 Phaser Camera）\n            // iPhone 等触屏设备默认开启；桌面端默认关闭（可手动开）。\n            const panToggle = document.getElementById('pan-toggle');\n            const isTouchDevice = IS_TOUCH_DEVICE;\n            let panEnabled = false;\n            let isPanning = false;\n            let panStart = null; // {x,y,sx,sy}\n            const camera = game.cameras.main;\n\n            const MAP_W = config.width;\n            const MAP_H = config.height;\n            function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }\n            function maxScrollX() {\n                const viewportW = camera.width / Math.max(0.01, camera.zoom);\n                return Math.max(0, MAP_W - viewportW);\n            }\n            function maxScrollY() {\n                const viewportH = camera.height / Math.max(0.01, camera.zoom);\n                return Math.max(0, MAP_H - viewportH);\n            }\n            function clampCameraScroll() {\n                camera.scrollX = clamp(camera.scrollX, 0, maxScrollX());\n                camera.scrollY = clamp(camera.scrollY, 0, maxScrollY());\n            }\n\n            // 手机上：锁定“办公室画布高度 = 2/3 区域高度”，\n            // 让世界坐标在竖向恰好看满（不需要上下拖），只保留横向拖动浏览左右。\n            // 记录初始是否已手动平移（避免 resize 时把用户拖好的位置重置）\n            let hasManuallyPanned = false;\n            function applyMobileCameraFit() {\n                if (!isTouchDevice) return;\n                const h = Math.max(1, camera.height);\n                const w = Math.max(1, camera.width);\n\n                // 关键：先按高度 fit，再看是否需要按宽度微调，\n                // 保证既不会让画面歪，又能左右拖到最左最右边缘不被裁。\n                const fitHeightZoom = h / MAP_H;\n                const candidateZoom = fitHeightZoom;\n\n                // 按 candidateZoom 计算：viewport 在世界坐标里的宽高\n                const viewW = w / candidateZoom;\n                const maxX = Math.max(0, MAP_W - viewW);\n\n                camera.setZoom(candidateZoom);\n                camera.scrollX = Math.min(camera.scrollX, maxX);\n                camera.scrollY = 0;\n\n                // 仅在未手动平移过时才居中（避免把用户拖好的位置冲掉）\n                if (!hasManuallyPanned) {\n                    camera.centerOn(MAP_W / 2, MAP_H / 2);\n                }\n                camera.scrollX = clamp(camera.scrollX, 0, maxX);\n                camera.scrollY = 0;\n            }\n            applyMobileCameraFit();\n\n            // 手机端旋转屏幕/地址栏伸缩时，重算 zoom + 夹紧 camera\n            if (isTouchDevice && game.scale) {\n                game.scale.on('resize', () => {\n                    applyMobileCameraFit();\n                    placeOverlayAndStatusAtCanvasBottomLeft();\n                });\n            }\n\n            camera.setBounds(0, 0, MAP_W, MAP_H);\n            clampCameraScroll();\n\n            function setPanEnabled(on) {\n                panEnabled = on;\n                if (panToggle) {\n                    panToggle.dataset.on = on ? '1' : '0';\n                    panToggle.textContent = on ? t('lockView') : t('moveView');\n                    panToggle.style.background = on ? '#e94560' : '#333';\n                }\n                game.input.setDefaultCursor(on ? 'grab' : 'default');\n                if (isTouchDevice && statusText) {\n                    const info = on ? '视野拖动已开启（可左右拖动画布）' : '视野拖动已关闭（点击左上角“移动视野”可开启）';\n                    statusText.textContent = `状态：[${(STATES[currentState] && STATES[currentState].name) || '待命'}] ${info}`;\n                }\n            }\n\n            if (panToggle) {\n                panToggle.addEventListener('click', () => setPanEnabled(!panEnabled));\n            }\n\n            // 手机端默认关闭拖动画面：由左上角“移动视野”开关显式开启\n            if (isTouchDevice) {\n                setPanEnabled(false);\n            }\n\n            // iOS/Safari 手势策略：\n            // - 保留垂直滚动（让页面能下滑看三个面板）\n            // - 水平方向拖动时才阻止默认行为，并转为 camera 横向平移\n            // 说明：iOS 对 pointer + touch-action 支持存在机型差异，所以这里加一套原生 touch 兜底。\n            const canvasEl = game.canvas;\n            let touchPan = null; // {x,y,sx,sy,lock:'x'|'y'|null}\n            if (canvasEl) {\n                // 手机端允许页面自然滚动，避免“不能滑动”\n                canvasEl.style.touchAction = 'auto';\n\n                canvasEl.addEventListener('touchstart', (e) => {\n                    if (!panEnabled || e.touches.length !== 1) return;\n                    const t = e.touches[0];\n                    touchPan = { x: t.clientX, y: t.clientY, sx: camera.scrollX, sy: camera.scrollY, lock: null };\n                }, { passive: true });\n\n                canvasEl.addEventListener('touchmove', (e) => {\n                    if (!panEnabled || !touchPan || e.touches.length !== 1) return;\n                    const t = e.touches[0];\n                    const dx = t.clientX - touchPan.x;\n                    const dy = t.clientY - touchPan.y;\n\n                    if (!touchPan.lock) {\n                        if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;\n                        touchPan.lock = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';\n                    }\n\n                    if (touchPan.lock === 'x') {\n                        // 横向拖动交给办公室视野；阻止浏览器默认滚动\n                        e.preventDefault();\n                        hasManuallyPanned = true;\n                        camera.scrollX = clamp(touchPan.sx - dx, 0, maxScrollX());\n                    }\n                    // lock==='y' 时不阻止默认，交给页面纵向滚动\n                }, { passive: false });\n\n                const clearTouchPan = () => { touchPan = null; };\n                canvasEl.addEventListener('touchend', clearTouchPan, { passive: true });\n                canvasEl.addEventListener('touchcancel', clearTouchPan, { passive: true });\n            }\n\n            game.input.on('pointerdown', (pointer) => {\n                if (!panEnabled) return;\n                isPanning = true;\n                panStart = { x: pointer.x, y: pointer.y, sx: camera.scrollX, sy: camera.scrollY };\n                game.input.setDefaultCursor('grabbing');\n            });\n\n            game.input.on('pointerup', () => {\n                if (!panEnabled) return;\n                isPanning = false;\n                panStart = null;\n                game.input.setDefaultCursor('grab');\n            });\n\n            game.input.on('pointermove', (pointer) => {\n                if (!panEnabled || !isPanning || !panStart) return;\n                const dx = pointer.x - panStart.x;\n                const dy = pointer.y - panStart.y;\n\n                // 手机端优先“横向拖动看办公室”，纵向手势留给页面滚动看下方面板。\n                if (isTouchDevice && Math.abs(dy) > Math.abs(dx)) {\n                    return;\n                }\n\n                // 手指向右拖，视野跟着向右看：camera scroll 向左减小（反向）\n                const newX = panStart.sx - dx;\n                hasManuallyPanned = true;\n                camera.scrollX = clamp(newX, 0, maxScrollX());\n\n                // 桌面端保留自由二维拖动\n                if (!isTouchDevice) {\n                    const newY = panStart.sy - dy;\n                    camera.scrollY = clamp(newY, 0, maxScrollY());\n                }\n            });\n\n            // Mouse move handler for coordinate display\n            game.input.on('pointermove', (pointer) => {\n                if (!showCoords) return;\n                // Clamp to map size (0..width-1 / 0..height-1)\n                const x = Math.max(0, Math.min(config.width - 1, Math.round(pointer.x)));\n                const y = Math.max(0, Math.min(config.height - 1, Math.round(pointer.y)));\n                coordsDisplay.textContent = `${x}, ${y}`;\n                // Position overlay next to mouse\n                coordsOverlay.style.left = (pointer.x + 18) + 'px';\n                coordsOverlay.style.top = (pointer.y + 18) + 'px';\n            });\n\n            // 加载昨日 memo\n            loadMemo();\n\n            fetchStatus();\n            fetchGuestAgents();\n        }\n\n        function update(time) {\n            if (time - lastFetch > FETCH_INTERVAL) { fetchStatus(); lastFetch = time; }\n            if (time - lastGuestAgentsFetch > GUEST_AGENTS_FETCH_INTERVAL) { fetchGuestAgents(); lastGuestAgentsFetch = time; }\n\n            // 兜底：非 idle 时确保机房动画在播，idle 时静止\n            const effectiveStateForServer = pendingDesiredState || currentState;\n            if (serverroom) {\n                if (effectiveStateForServer === 'idle') {\n                    if (serverroom.anims.isPlaying) {\n                        serverroom.anims.stop();\n                        serverroom.setFrame(0);\n                    }\n                } else {\n                    if (!serverroom.anims.isPlaying || serverroom.anims.currentAnim?.key !== 'serverroom_on') {\n                        serverroom.anims.play('serverroom_on', true);\n                    }\n                }\n            }\n\n            // error 状态：显示 bug 动画，并在两点之间来回移动\n            if (window.errorBug) {\n                if (effectiveStateForServer === 'error') {\n                    window.errorBug.setVisible(true);\n                    if (!window.errorBug.anims.isPlaying || window.errorBug.anims.currentAnim?.key !== 'error_bug') {\n                        window.errorBug.anims.play('error_bug', true);\n                    }\n                    // 固定在原地（按需求取消 error 移动路径）\n                    window.errorBug.x = 1007;\n                    window.errorBug.y = 221;\n                } else {\n                    window.errorBug.setVisible(false);\n                    window.errorBug.anims.stop();\n                }\n            }\n\n            // Sync animation fallback logic\n            if (syncAnimSprite) {\n                if (effectiveStateForServer === 'syncing') {\n                    if (syncAnimPlayable && syncAnimSprite.anims && syncAnimSprite.anims.play && syncAnimSprite.scene?.anims?.exists('sync_anim')) {\n                    if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n                        syncAnimSprite.anims.play('sync_anim', true);\n                    }\n                } else {\n                        syncAnimSprite.setFrame(0);\n                    }\n                } else {\n                    if (syncAnimSprite.anims && syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n                    syncAnimSprite.setFrame(0);\n                }\n            }\n\n            // 冒气泡\n            if (time - lastBubble > BUBBLE_INTERVAL) {\n                showBubble();\n                lastBubble = time;\n            }\n            // 猫的气泡（频率低）\n            if (time - lastCatBubble > CAT_BUBBLE_INTERVAL) {\n                showCatBubble();\n                lastCatBubble = time;\n            }\n\n            // 打字机效果\n            if (typewriterIndex < typewriterTarget.length && time - lastTypewriter > TYPEWRITER_DELAY) {\n                typewriterText += typewriterTarget[typewriterIndex];\n                statusText.textContent = typewriterText;\n                typewriterIndex++;\n                lastTypewriter = time;\n            }\n\n            // 移动 + 小踱步\n            moveStar(time);\n\n            // guest 随机想法泡泡\n            maybeShowGuestBubble(time);\n\n            // demo 平滑移动时：让气泡每帧跟随角色锚点（避免 tween 时气泡滞留在旧位置）\n            try {\n                Object.keys(guestBubbles).forEach(id => {\n                    const b = guestBubbles[id];\n                    const g = guestSprites[id];\n                    if (!b || !g) return;\n                    if (b.__followAgentId !== id) return;\n                    b.x = 0;\n                    b.y = 0;\n                    // children[0]=bg, children[1]=text\n                    const bx = g.sprite.x;\n                    const isDemoGuest = (id === 'demo_nika' || id === 'demo_mercury');\n                    const nameH = (g.nameText && g.nameText.height) ? g.nameText.height : 16;\n                    const by = isDemoGuest ? (g.sprite.y - 90) : ((g.nameText ? g.nameText.y : (g.sprite.y - 150)) - (nameH / 2) - 22);\n                    if (b.list && b.list[0]) { b.list[0].x = bx; b.list[0].y = by; }\n                    if (b.list && b.list[1]) { b.list[1].x = bx; b.list[1].y = by; }\n                });\n            } catch (e) {}\n\n            // guest 列表会定时刷新\n        }\n\n        function normalizeState(s) {\n            if (!s) return 'idle';\n            if (s === 'working') return 'writing';\n            if (s === 'run' || s === 'running') return 'executing';\n            if (s === 'sync') return 'syncing';\n            if (s === 'research') return 'researching';\n            return s;\n        }\n\n        function applyVisualState(nextState) {\n            // Idle: show Star idle animation (main character)\n            if (nextState === 'idle') {\n                sofa.anims.stop();\n                sofa.setTexture('sofa_idle');\n\n                if (window.starWorking) {\n                    window.starWorking.setVisible(false);\n                    window.starWorking.anims.stop();\n                }\n\n                star.setVisible(true);\n                star.setScale(IDLE_STAR_SCALE);\n                star.anims.play('star_idle', true);\n                star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n            } else if (nextState === 'error') {\n                // Error: no working animation at desk\n                sofa.anims.stop();\n                sofa.setTexture('sofa_idle');\n                star.setVisible(false);\n                star.anims.stop();\n                if (window.starWorking) {\n                    window.starWorking.setVisible(false);\n                    window.starWorking.anims.stop();\n                }\n            } else if (nextState === 'syncing') {\n                // Syncing: also no working animation at desk\n                sofa.anims.stop();\n                sofa.setTexture('sofa_idle');\n                star.setVisible(false);\n                star.anims.stop();\n                if (window.starWorking) {\n                    window.starWorking.setVisible(false);\n                    window.starWorking.anims.stop();\n                }\n            } else {\n                // Non-idle non-error: starWorking animation at desk\n                sofa.anims.stop();\n                sofa.setTexture('sofa_idle');\n                // Hide moving star, show desk star\n                star.setVisible(false);\n                star.anims.stop();\n                if (window.starWorking) {\n                    window.starWorking.setVisible(true);\n                    window.starWorking.anims.play('star_working', true);\n                }\n            }\n\n            // Server room logic\n            if (serverroom) {\n                if (nextState === 'idle') {\n                    serverroom.anims.stop();\n                    serverroom.setFrame(0);\n                } else {\n                    serverroom.anims.play('serverroom_on', true);\n                }\n            }\n\n            // Sync animation logic: default frame0; syncing play loop\n            if (syncAnimSprite) {\n                if (nextState === 'syncing') {\n                    if (syncAnimPlayable && syncAnimSprite.anims && syncAnimSprite.anims.play && syncAnimSprite.scene?.anims?.exists('sync_anim')) {\n                        if (!syncAnimSprite.anims.isPlaying || syncAnimSprite.anims.currentAnim?.key !== 'sync_anim') {\n                            syncAnimSprite.anims.play('sync_anim', true);\n                        }\n                    } else {\n                        syncAnimSprite.setFrame(0);\n                    }\n                } else {\n                    if (syncAnimSprite.anims && syncAnimSprite.anims.isPlaying) syncAnimSprite.anims.stop();\n                    syncAnimSprite.setFrame(0);\n                }\n            }\n        }\n\n        function fetchStatus() {\n            return fetch('/status', { cache: 'no-store' })\n                .then(response => response.json())\n                .then(data => {\n                    try {\n                        if (data.officeName) {\n                            window.officeNameFromServer = data.officeName;\n                            if (window.officePlaqueText && window.officePlaqueText.setText) {\n                                window.officePlaqueText.setText(data.officeName);\n                            }\n                        }\n                        const nextState = normalizeState(data.state);\n                        const stateInfo = STATES[nextState] || STATES.idle;\n                        // If we're mid-transition, don't restart the path every poll\n                        const changed = (pendingDesiredState === null) && (nextState !== currentState);\n                        const nextLine = '[' + stateInfo.name + '] ' + (data.detail || '...');\n                        // 不论 changed 与否，都按服务端最新状态强制一次视觉同步，避免动画卡旧状态\n                        pendingDesiredState = null;\n                        if (currentState !== nextState) {\n                            currentState = nextState;\n                        }\n                        applyVisualState(nextState);\n\n                        if (changed) {\n                            typewriterTarget = nextLine;\n                            typewriterText = '';\n                            typewriterIndex = 0;\n                        } else {\n                            if (!typewriterTarget || typewriterTarget !== nextLine) {\n                                typewriterTarget = nextLine;\n                                typewriterText = '';\n                                typewriterIndex = 0;\n                            }\n                        }\n                    } catch (err) {\n                        console.error('fetchStatus apply error', err);\n                        typewriterTarget = '状态更新异常，正在恢复...';\n                        typewriterText = '';\n                        typewriterIndex = 0;\n                    }\n                })\n                .catch(error => {\n                    typewriterTarget = '连接失败，正在重试...';\n                    typewriterText = '';\n                    typewriterIndex = 0;\n                });\n        }\n\n        function moveStar(time) {\n            // Use pending state if available (for target area during transition)\n            const effectiveState = pendingDesiredState || currentState;\n            const stateInfo = STATES[effectiveState] || STATES.idle;\n            const baseTarget = areas[stateInfo.area] || areas.breakroom;\n\n            // idle 时锁定位置（不走任何移动路径）\n            if (effectiveState === 'idle') {\n                if (star && star.visible) {\n                    star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n                }\n                isMoving = false;\n                return;\n            }\n\n            const dx = targetX - star.x;\n            const dy = targetY - star.y;\n            const dist = Math.sqrt(dx * dx + dy * dy);\n            const speed = 1.4;\n            const wobble = Math.sin(time / 200) * 0.8;\n\n            if (dist > 3) {\n                // Move toward current target\n                star.x += (dx / dist) * speed;\n                star.y += (dy / dist) * speed;\n                star.setY(star.y + wobble);\n                isMoving = true;\n            } else {\n                // Arrived at a waypoint or final target\n                if (waypoints && waypoints.length > 0) {\n                    // Remove the first waypoint (we just arrived there)\n                    waypoints.shift();\n                    if (waypoints.length > 0) {\n                        // Next waypoint exists\n                        targetX = waypoints[0].x;\n                        targetY = waypoints[0].y;\n                        isMoving = true;\n                    } else {\n                        // Final target: apply pending state and switch visual\n                        if (pendingDesiredState !== null) {\n                            isMoving = false;\n                            currentState = pendingDesiredState;\n                            pendingDesiredState = null;\n\n                            if (currentState === 'idle') {\n                                if (window.starWorking) {\n                                    window.starWorking.setVisible(false);\n                                    window.starWorking.anims.stop();\n                                }\n                                star.setVisible(true);\n                                star.setScale(IDLE_STAR_SCALE);\n                                star.anims.play('star_idle', true);\n                                star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n                                sofa.anims.stop();\n                                sofa.setTexture('sofa_idle');\n                            } else {\n                                // Arrived at desk area: switch to star_working animation\n                                star.setVisible(false);\n                                star.anims.stop();\n                                if (window.starWorking) {\n                                    window.starWorking.setVisible(true);\n                                    window.starWorking.anims.play('star_working', true);\n                                }\n                            }\n                        }\n                    }\n                } else {\n                    if (pendingDesiredState !== null) {\n                        isMoving = false;\n                        currentState = pendingDesiredState;\n                        pendingDesiredState = null;\n\n                        if (currentState === 'idle') {\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(false);\n                                window.starWorking.anims.stop();\n                            }\n                            star.setVisible(true);\n                            star.setScale(IDLE_STAR_SCALE);\n                            star.anims.play('star_idle', true);\n                            star.setPosition(IDLE_SOFA_ANCHOR.x, IDLE_SOFA_ANCHOR.y);\n                            sofa.anims.stop();\n                            sofa.setTexture('sofa_idle');\n                        } else {\n                            // Arrived at desk area: switch to star_working animation\n                            star.setVisible(false);\n                            star.anims.stop();\n                            if (window.starWorking) {\n                                window.starWorking.setVisible(true);\n                                window.starWorking.anims.play('star_working', true);\n                            }\n                            sofa.anims.stop();\n                            sofa.setTexture('sofa_idle');\n                        }\n                    }\n                }\n            }\n\n            // Small wander only after arrival (non-idle)\n            // Temporarily disabled to stay in work area; uncomment later if needed\n            /*\n            if (!isMoving && currentState !== 'idle' && pendingDesiredState === null && (time - lastWanderAt) > 3500) {\n                targetX = baseTarget.x + (Math.random() - 0.5) * 60;\n                targetY = baseTarget.y + (Math.random() - 0.5) * 40;\n                star.setVisible(true);\n                star.anims.play('star_idle', true);\n                isMoving = true;\n                lastWanderAt = time;\n            }\n            */\n        }\n\n        function getBubbleTextsByState(stateKey) {\n            const langPack = BUBBLE_TEXTS[uiLang] || BUBBLE_TEXTS.zh;\n            return langPack[stateKey] || langPack.idle || [];\n        }\n\n        function showBubble() {\n            if (bubble) { bubble.destroy(); bubble = null; }\n            const texts = getBubbleTextsByState(currentState);\n            if (currentState === 'idle') return; // idle 不显示气泡（可按需开启）\n\n            // Bubble anchor should follow current visible character:\n            // - syncing: syncAnimSprite\n            // - error state: errorBug\n            // - working at desk: starWorking\n            // - other: star\n            let anchorX = star.x;\n            let anchorY = star.y;\n            if (currentState === 'syncing' && syncAnimSprite && syncAnimSprite.visible) {\n                anchorX = syncAnimSprite.x;\n                anchorY = syncAnimSprite.y;\n            } else if (currentState === 'error' && window.errorBug && window.errorBug.visible) {\n                anchorX = window.errorBug.x;\n                anchorY = window.errorBug.y;\n            } else if (!star.visible && window.starWorking && window.starWorking.visible) {\n                anchorX = window.starWorking.x;\n                anchorY = window.starWorking.y;\n            }\n\n            const text = texts[Math.floor(Math.random() * texts.length)];\n            const bubbleOffsetY = (currentState === 'writing') ? 85 : 70;\n            const bubbleY = anchorY - bubbleOffsetY;\n\n            // 只做手机端稍微调大一点，避免发糊\n            const isTouch = IS_TOUCH_DEVICE;\n            const fontSize = isTouch ? 14 : 12;\n            const bg = game.add.rectangle(anchorX, bubbleY, text.length * 10 + 20, 28, 0xffffff, 0.95);\n            bg.setStrokeStyle(2, 0x000000);\n            const txt = game.add.text(anchorX, bubbleY, text, { fontFamily: 'ArkPixel, monospace', fontSize: fontSize + 'px', fill: '#000', align: 'center' }).setOrigin(0.5);\n            bubble = game.add.container(0, 0, [bg, txt]);\n            bubble.setDepth(1200); // always above desk/star\n            setTimeout(() => { if (bubble) { bubble.destroy(); bubble = null; } }, 3000);\n        }\n\n        function showCatBubble() {\n            if (!window.catSprite) return;\n            if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; }\n            const texts = getBubbleTextsByState('cat');\n            const text = texts[Math.floor(Math.random() * texts.length)];\n            const anchorX = window.catSprite.x;\n            const anchorY = window.catSprite.y - 60;\n            const bg = game.add.rectangle(anchorX, anchorY, text.length * 10 + 20, 24, 0xfffbeb, 0.95);\n            bg.setStrokeStyle(2, 0xd4a574);\n            const txt = game.add.text(anchorX, anchorY, text, { fontFamily: 'ArkPixel, monospace', fontSize: '11px', fill: '#8b6914', align: 'center' }).setOrigin(0.5);\n            window.catBubble = game.add.container(0, 0, [bg, txt]);\n            window.catBubble.setDepth(2100); // top layer above cat\n            setTimeout(() => { if (window.catBubble) { window.catBubble.destroy(); window.catBubble = null; } }, 4000);\n        }\n\n        // 假 Agent 气泡逻辑已移除，统一以真实 /agents 数据为准\n\n        // 启动游戏\n        initGame();\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/invite.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>海辛办公室 - 加入邀请</title>\n    <style>\n        body {\n            margin: 0;\n            padding: 40px;\n            font-family: \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n            background: linear-gradient(135deg, #f5f7fa 0%, #e4edf5 100%);\n            min-height: 100vh;\n            box-sizing: border-box;\n        }\n        .card {\n            max-width: 800px;\n            margin: 0 auto;\n            background: white;\n            padding: 48px;\n            border-radius: 16px;\n            box-shadow: 0 10px 40px rgba(0,0,0,0.08);\n        }\n        h1 {\n            margin-top: 0;\n            color: #111827;\n            font-size: 28px;\n        }\n        h2 {\n            margin-top: 32px;\n            color: #1f2937;\n            font-size: 18px;\n        }\n        p, li {\n            color: #374151;\n            line-height: 1.8;\n        }\n        .steps {\n            background: #f9fafb;\n            padding: 24px;\n            border-radius: 12px;\n            margin: 16px 0;\n        }\n        .step {\n            display: flex;\n            gap: 16px;\n            margin-bottom: 16px;\n        }\n        .step:last-child {\n            margin-bottom: 0;\n        }\n        .step-num {\n            width: 28px;\n            height: 28px;\n            border-radius: 50%;\n            background: #3b82f6;\n            color: white;\n            font-weight: 600;\n            display: flex;\n            align-items: center;\n            justify-content: center;\n            flex-shrink: 0;\n            font-size: 14px;\n        }\n        .step-text {\n            flex: 1;\n        }\n        .step-text strong {\n            color: #111827;\n        }\n        .join-link {\n            background: #f3f4f6;\n            padding: 16px;\n            border-radius: 8px;\n            font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n            font-size: 14px;\n            word-break: break-all;\n            margin-top: 8px;\n        }\n        .note {\n            margin-top: 24px;\n            padding: 16px;\n            border-left: 4px solid #f59e0b;\n            background: #fffbeb;\n            border-radius: 0 8px 8px 0;\n        }\n        .note strong {\n            color: #92400e;\n        }\n        .footer {\n            margin-top: 32px;\n            padding-top: 24px;\n            border-top: 1px solid #e5e7eb;\n            color: #6b7280;\n            font-size: 14px;\n        }\n        .back-btn {\n            display: inline-block;\n            margin-top: 24px;\n            padding: 12px 24px;\n            background: #3b82f6;\n            color: white;\n            text-decoration: none;\n            border-radius: 8px;\n            font-weight: 500;\n        }\n        .back-btn:hover {\n            background: #2563eb;\n        }\n    </style>\n</head>\n<body>\n    <div class=\"card\">\n        <h1>✨ 海辛办公室 · 加入邀请</h1>\n        <p>欢迎加入海辛的像素办公室看板！</p>\n\n        <h2>加入步骤（一共 3 步）</h2>\n        <div class=\"steps\">\n            <div class=\"step\">\n                <div class=\"step-num\">1</div>\n                <div class=\"step-text\">\n                    <strong>确认信息</strong><br>\n                    你应该已经收到两样东西：\n                    <ul>\n                        <li>邀请链接：<code>https://office.example.com/join</code></li>\n                        <li>一次性接入密钥（join key）：<code>ocj_xxx</code></li>\n                    </ul>\n                </div>\n            </div>\n            <div class=\"step\">\n                <div class=\"step-num\">2</div>\n                <div class=\"step-text\">\n                    <strong>把邀请信息丢给你的 OpenClaw</strong><br>\n                    把邀请链接 + join key 一起发给你的 OpenClaw，并说“帮我加入海辛办公室”。\n                </div>\n            </div>\n            <div class=\"step\">\n                <div class=\"step-num\">3</div>\n                <div class=\"step-text\">\n                    <strong>在你这边授权</strong><br>\n                    你的 OpenClaw 会在对话里向你要授权；同意后，它就会开始自动把工作状态推送到海辛办公室看板啦！\n                </div>\n            </div>\n        </div>\n\n        <div class=\"note\">\n            <strong>⚠️  隐私说明</strong><br>\n            只推送状态（idle/writing/researching/executing/syncing/error），不含任何具体内容/隐私；随时可停。\n        </div>\n\n        <a href=\"/\" class=\"back-btn\">← 回到海辛办公室</a>\n\n        <div class=\"footer\">\n            海辛工作室 · 像素办公室看板<br>\n            有问题找海辛 😊\n        </div>\n    </div>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/join-office-skill.md",
    "content": "# Join Star Office - Visitor Agent Skill\n\n## Description\n接入海辛的像素办公室，让你的龙虾在看板上有一个工位，实时显示工作状态。\n\n## Prerequisites\n- 你需要一个接入密钥（join key），格式：`ocj_xxx`\n- 你的主人已同意授权你推送状态\n- 你能访问 `https://office.hyacinth.im`\n\n## Quick Start（最快上手）\n\n### Step 1: 向你的主人确认授权\n> \"我将把我的工作状态（idle/writing/researching/executing/syncing/error）定时发送到海辛的像素办公室看板。只推送状态词和简短描述，不含任何隐私内容，随时可停。是否授权？\"\n\n### Step 2: 下载推送脚本\n```bash\ncurl -o office-agent-push.py https://office.hyacinth.im/static/office-agent-push.py\n```\n\n### Step 3: 配置并运行\n打开 `office-agent-push.py`，填写以下 3 个变量：\n\n```python\nJOIN_KEY = \"你的接入密钥\"        # 例如 ocj_starteam02\nAGENT_NAME = \"你的名字\"          # 例如 \"小明的龙虾\"\nOFFICE_URL = \"https://office.hyacinth.im\"\n```\n\n然后运行：\n```bash\npython3 office-agent-push.py\n```\n\n### Step 4: 完成！\n脚本会自动：\n- 加入海辛的办公室（自动批准，无需等待）\n- 每 15 秒读取你的本地状态并推送\n- 你的龙虾会出现在办公室看板上，根据状态自动走到不同区域\n\n## 状态区域映射\n| 状态 | 办公室区域 | 说明 |\n|------|-----------|------|\n| idle | 休息区（沙发） | 待命 / 完成任务 |\n| writing | 工作区（办公桌） | 写代码 / 写文档 |\n| researching | 工作区 | 搜索 / 调研 |\n| executing | 工作区 | 执行任务 |\n| syncing | 工作区 | 同步数据 |\n| error | Bug 区 | 报错 / 异常 |\n\n## 本地状态读取优先级\n脚本会按以下顺序自动发现你的状态源（无需手动配置）：\n1. `state.json`（本机 OpenClaw 工作区，自动发现多个候选路径）\n2. `http://127.0.0.1:19000/status`（本地 HTTP 接口）\n3. 默认 fallback：idle\n\n如果你的状态文件路径特殊，可以用环境变量指定：\n```bash\nOFFICE_LOCAL_STATE_FILE=/你的/state.json python3 office-agent-push.py\n```\n\n## 停止推送\n- `Ctrl+C` 终止脚本\n- 脚本会自动从办公室退出\n\n## Notes\n- 只推送状态词和简短描述，不推送任何隐私内容\n- 授权有效期 24h，到期后需要重新 join\n- 如果收到 403（密钥过期）或 404（已被移出），脚本会自动停止\n- 同一密钥最多支持 100 个龙虾同时在线\n"
  },
  {
    "path": "frontend/join.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    <title>加入 Star 的像素办公室</title>\n    <style>\n        @font-face {\n            font-family: 'ArkPixel';\n            src: url('/static/fonts/ark-pixel-12px-proportional-zh_cn.ttf.woff2') format('woff2');\n            font-weight: normal;\n            font-style: normal;\n        }\n        * { margin: 0; padding: 0; box-sizing: border-box; }\n        body {\n            background: #1a1a2e;\n            display: flex;\n            flex-direction: column;\n            justify-content: center;\n            align-items: center;\n            min-height: 100vh;\n            font-family: 'ArkPixel', 'Courier New', monospace;\n            padding: 40px 20px;\n            gap: 30px;\n            color: #fff;\n        }\n        h1 {\n            color: #ffd700;\n            font-size: 24px;\n            text-align: center;\n        }\n        .container {\n            background: #2c2f3a;\n            border: 3px solid #e94560;\n            border-radius: 12px;\n            padding: 24px;\n            width: 100%;\n            max-width: 480px;\n            box-shadow: 0 8px 30px rgba(0,0,0,0.6);\n        }\n        .form-group {\n            margin-bottom: 18px;\n        }\n        label {\n            display: block;\n            margin-bottom: 8px;\n            font-size: 14px;\n            color: #ddd;\n        }\n        input, select {\n            width: 100%;\n            padding: 10px 12px;\n            font-family: 'ArkPixel', monospace;\n            font-size: 14px;\n            border: 2px solid #555;\n            border-radius: 6px;\n            background: #3a3f4f;\n            color: #fff;\n        }\n        button {\n            width: 100%;\n            padding: 12px;\n            font-family: 'ArkPixel', monospace;\n            font-size: 16px;\n            border: 2px solid #e94560;\n            border-radius: 6px;\n            background: #e94560;\n            color: #fff;\n            cursor: pointer;\n            transition: all 0.2s;\n        }\n        button:hover {\n            background: #ff6b81;\n        }\n        .status {\n            margin-top: 16px;\n            padding: 12px;\n            border-radius: 6px;\n            text-align: center;\n            font-size: 14px;\n        }\n        .status.ok {\n            background: rgba(76, 175, 80, 0.2);\n            color: #4caf50;\n            border: 2px solid #4caf50;\n        }\n        .status.error {\n            background: rgba(244, 67, 54, 0.2);\n            color: #f44336;\n            border: 2px solid #f44336;\n        }\n        .note {\n            font-size: 12px;\n            color: #888;\n            margin-top: 16px;\n            text-align: center;\n            line-height: 1.8;\n        }\n        .note a { word-break: break-all; }\n    </style>\n</head>\n<body>\n    <h1>⭐ 加入 Star 的像素办公室</h1>\n    <div class=\"container\">\n        <div class=\"form-group\">\n            <label>你的名字（会显示在办公室）</label>\n            <input type=\"text\" id=\"agentName\" placeholder=\"例如：小龙虾助手\" maxlength=\"20\">\n        </div>\n        <!-- 状态与细节改为自动同步，不在 join 页面填写 -->\n        <div class=\"form-group\">\n            <label>Agent 接入密钥（一次性）</label>\n            <input type=\"text\" id=\"joinKey\" placeholder=\"请输入你拿到的 join key\" maxlength=\"64\">\n        </div>\n        <button id=\"joinBtn\">加入办公室</button>\n        <button id=\"leaveBtn\" style=\"margin-top:10px; background:#555; border-color:#555;\">离开办公室</button>\n        <div id=\"status\" class=\"status\" style=\"display:none;\"></div>\n    </div>\n    <div class=\"note\">\n        ⚠️ 注意：join 页面仅需要名字 + 一次性 join key<br>\n        状态与状态细节会由 agent 后续自动推送同步\n        <br><br>\n        📌 邀请说明：\n        <a href=\"/invite\" style=\"color:#ffd700; text-decoration: underline;\">https://office.example.com/invite</a>\n    </div>\n\n    <script>\n        const joinBtn = document.getElementById('joinBtn');\n        const leaveBtn = document.getElementById('leaveBtn');\n        const statusDiv = document.getElementById('status');\n        const agentNameInput = document.getElementById('agentName');\n        const joinKeyInput = document.getElementById('joinKey');\n\n        function showStatus(text, ok) {\n            statusDiv.style.display = 'block';\n            statusDiv.textContent = text;\n            statusDiv.className = 'status ' + (ok ? 'ok' : 'error');\n        }\n\n        async function join() {\n            const name = agentNameInput.value.trim();\n            const joinKey = joinKeyInput.value.trim();\n            if (!name) {\n                showStatus('请先输入你的名字～', false);\n                return;\n            }\n            if (!joinKey) {\n                showStatus('请先输入 Agent 接入密钥～', false);\n                return;\n            }\n            try {\n                const response = await fetch('/join-agent', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ name, joinKey })\n                });\n                const data = await response.json();\n                if (data.ok) {\n                    showStatus('加入成功！刷新办公室就能看到你啦 ✨', true);\n                } else {\n                    showStatus(data.msg || '加入失败', false);\n                }\n            } catch (e) {\n                showStatus('网络出错，请重试', false);\n            }\n        }\n\n        async function leave() {\n            const name = agentNameInput.value.trim();\n            if (!name) {\n                showStatus('请先输入你要离开的名字～', false);\n                return;\n            }\n            try {\n                const response = await fetch('/leave-agent', {\n                    method: 'POST',\n                    headers: { 'Content-Type': 'application/json' },\n                    body: JSON.stringify({ name })\n                });\n                const data = await response.json();\n                if (data.ok) {\n                    showStatus('已离开办公室 👋', true);\n                } else {\n                    showStatus(data.msg || '离开失败', false);\n                }\n            } catch (e) {\n                showStatus('网络出错，请重试', false);\n            }\n        }\n\n        joinBtn.addEventListener('click', join);\n        leaveBtn.addEventListener('click', leave);\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "frontend/layout.js",
    "content": "// Star Office UI - 布局与层级配置\n// 所有坐标、depth、资源路径统一管理在这里\n// 避免 magic numbers，降低改错风险\n\n// 核心规则：\n// - 透明资源（如办公桌）强制 .png，不透明优先 .webp\n// - 层级：低 → sofa(10) → starWorking(900) → desk(1000) → flower(1100)\n\nconst LAYOUT = {\n  // === 游戏画布 ===\n  game: {\n    width: 1280,\n    height: 720\n  },\n\n  // === 各区域坐标 ===\n  areas: {\n    door:        { x: 640, y: 550 },\n    writing:     { x: 320, y: 360 },\n    researching: { x: 320, y: 360 },\n    error:       { x: 1066, y: 180 },\n    breakroom:   { x: 640, y: 360 }\n  },\n\n  // === 装饰与家具：坐标 + 原点 + depth ===\n  furniture: {\n    // 沙发\n    sofa: {\n      x: 670,\n      y: 144,\n      origin: { x: 0, y: 0 },\n      depth: 10\n    },\n\n    // 新办公桌（透明 PNG 强制）\n    desk: {\n      x: 218,\n      y: 417,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 1000\n    },\n\n    // 桌上花盆\n    flower: {\n      x: 310,\n      y: 390,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 1100,\n      scale: 0.8\n    },\n\n    // Star 在桌前工作（在 desk 下面）\n    starWorking: {\n      x: 217,\n      y: 333,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 900,\n      scale: 1.32\n    },\n\n    // 植物们\n    plants: [\n      { x: 565, y: 178, depth: 5 },\n      { x: 230, y: 185, depth: 5 },\n      { x: 977, y: 496, depth: 5 }\n    ],\n\n    // 海报\n    poster: {\n      x: 252,\n      y: 66,\n      depth: 4\n    },\n\n    // 咖啡机\n    coffeeMachine: {\n      x: 659,\n      y: 397,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 99\n    },\n\n    // 服务器区\n    serverroom: {\n      x: 1021,\n      y: 142,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 2\n    },\n\n    // 错误 bug\n    errorBug: {\n      x: 1007,\n      y: 221,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 50,\n      scale: 0.9,\n      pingPong: { leftX: 1007, rightX: 1111, speed: 0.6 }\n    },\n\n    // 同步动画\n    syncAnim: {\n      x: 1157,\n      y: 592,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 40\n    },\n\n    // 小猫\n    cat: {\n      x: 94,\n      y: 557,\n      origin: { x: 0.5, y: 0.5 },\n      depth: 2000\n    }\n  },\n\n  // === 牌匾 ===\n  plaque: {\n    x: 640,\n    y: 720 - 36,\n    width: 420,\n    height: 44\n  },\n\n  // === 资源加载规则：哪些强制用 PNG（透明资源） ===\n  forcePng: {\n    desk_v2: true // 新办公桌必须透明，强制 PNG\n  },\n\n  // === 总资源数量（用于加载进度条） ===\n  totalAssets: 15\n};\n"
  },
  {
    "path": "frontend/office-agent-push.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n海辛办公室 - Agent 状态主动推送脚本\n\n用法：\n1. 填入下面的 JOIN_KEY（你从海辛那里拿到的一次性 join key）\n2. 填入 AGENT_NAME（你想要在办公室里显示的名字）\n3. 运行：python office-agent-push.py\n4. 脚本会自动先 join（首次运行），然后每 30s 向海辛办公室推送一次你的当前状态\n\"\"\"\n\nimport json\nimport os\nimport time\nimport sys\nfrom datetime import datetime\n\n# === 你需要填入的信息 ===\nJOIN_KEY = \"\"   # 必填：你的一次性 join key\nAGENT_NAME = \"\" # 必填：你在办公室里的名字\nOFFICE_URL = \"https://office.hyacinth.im\"  # 海辛办公室地址（一般不用改）\n\n# === 推送配置 ===\nPUSH_INTERVAL_SECONDS = 15  # 每隔多少秒推送一次（更实时）\nSTATUS_ENDPOINT = \"/status\"\nJOIN_ENDPOINT = \"/join-agent\"\nPUSH_ENDPOINT = \"/agent-push\"\n\n# 自动状态守护：当本地状态文件不存在或长期不更新时，自动回 idle，避免“假工作中”\nSTALE_STATE_TTL_SECONDS = int(os.environ.get(\"OFFICE_STALE_STATE_TTL\", \"600\"))\n\n# 本地状态存储（记住上次 join 拿到的 agentId）\nSTATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"office-agent-state.json\")\n\n# 优先读取本机 OpenClaw 工作区的状态文件（更贴合 AGENTS.md 的工作流）\n# 支持自动发现，减少对方手动配置成本。\nDEFAULT_STATE_CANDIDATES = [\n    \"/root/.openclaw/workspace/Star-Office-UI/state.json\",  # 当前仓库（大小写精确）\n    \"/root/.openclaw/workspace/star-office-ui/state.json\",  # 历史/兼容路径\n    \"/root/.openclaw/workspace/state.json\",\n    os.path.join(os.getcwd(), \"state.json\"),\n    os.path.join(os.path.dirname(os.path.abspath(__file__)), \"state.json\"),\n]\n\n# 如果对方本地 /status 需要鉴权，可在这里填写 token（或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN）\nLOCAL_STATUS_TOKEN = os.environ.get(\"OFFICE_LOCAL_STATUS_TOKEN\", \"\")\nLOCAL_STATUS_URL = os.environ.get(\"OFFICE_LOCAL_STATUS_URL\", \"http://127.0.0.1:19000/status\")\n# 可选：直接指定本地状态文件路径（最简单方案：绕过 /status 鉴权）\nLOCAL_STATE_FILE = os.environ.get(\"OFFICE_LOCAL_STATE_FILE\", \"\")\nVERBOSE = os.environ.get(\"OFFICE_VERBOSE\", \"0\") in {\"1\", \"true\", \"TRUE\", \"yes\", \"YES\"}\n\n\ndef load_local_state():\n    if os.path.exists(STATE_FILE):\n        try:\n            with open(STATE_FILE, \"r\", encoding=\"utf-8\") as f:\n                return json.load(f)\n        except Exception:\n            pass\n    return {\n        \"agentId\": None,\n        \"joined\": False,\n        \"joinKey\": JOIN_KEY,\n        \"agentName\": AGENT_NAME\n    }\n\n\ndef save_local_state(data):\n    with open(STATE_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(data, f, ensure_ascii=False, indent=2)\n\n\ndef normalize_state(s):\n    \"\"\"兼容不同本地状态词，并映射到办公室识别状态。\"\"\"\n    s = (s or \"\").strip().lower()\n    if s in {\"writing\", \"researching\", \"executing\", \"syncing\", \"error\", \"idle\"}:\n        return s\n    if s in {\"working\", \"busy\", \"write\"}:\n        return \"writing\"\n    if s in {\"run\", \"running\", \"execute\", \"exec\"}:\n        return \"executing\"\n    if s in {\"research\", \"search\"}:\n        return \"researching\"\n    if s in {\"sync\"}:\n        return \"syncing\"\n    return \"idle\"\n\n\ndef map_detail_to_state(detail, fallback_state=\"idle\"):\n    \"\"\"当只有 detail 时，用关键词推断状态（贴近 AGENTS.md 的办公区逻辑）。\"\"\"\n    d = (detail or \"\").lower()\n    if any(k in d for k in [\"报错\", \"error\", \"bug\", \"异常\", \"报警\"]):\n        return \"error\"\n    if any(k in d for k in [\"同步\", \"sync\", \"备份\"]):\n        return \"syncing\"\n    if any(k in d for k in [\"调研\", \"research\", \"搜索\", \"查资料\"]):\n        return \"researching\"\n    if any(k in d for k in [\"执行\", \"run\", \"推进\", \"处理任务\", \"工作中\", \"writing\"]):\n        return \"writing\"\n    if any(k in d for k in [\"待命\", \"休息\", \"idle\", \"完成\", \"done\"]):\n        return \"idle\"\n    return fallback_state\n\n\ndef _state_age_seconds(data):\n    try:\n        ts = (data or {}).get(\"updated_at\")\n        if not ts:\n            return None\n        dt = datetime.fromisoformat(str(ts).replace(\"Z\", \"+00:00\"))\n        if dt.tzinfo is not None:\n            from datetime import timezone\n            return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds()\n        return (datetime.now() - dt).total_seconds()\n    except Exception:\n        return None\n\n\ndef fetch_local_status():\n    \"\"\"读取本地状态：\n    1) 优先 state.json（符合 AGENTS.md：任务前切 writing，完成后切 idle）\n    2) 其次尝试本地 HTTP /status\n    3) 最后 fallback idle\n\n    额外防抖：如果本地状态更新时间超过 STALE_STATE_TTL_SECONDS，自动视为 idle。\n    \"\"\"\n    # 1) 读本地 state.json（优先读取显式指定路径，其次自动发现）\n    candidate_files = []\n    if LOCAL_STATE_FILE:\n        candidate_files.append(LOCAL_STATE_FILE)\n    for fp in DEFAULT_STATE_CANDIDATES:\n        if fp not in candidate_files:\n            candidate_files.append(fp)\n\n    for fp in candidate_files:\n        try:\n            if fp and os.path.exists(fp):\n                with open(fp, \"r\", encoding=\"utf-8\") as f:\n                    data = json.load(f)\n\n                    # 只接受“状态文件”结构；避免误把 office-agent-state.json（仅缓存 agentId）当状态源\n                    if not isinstance(data, dict):\n                        continue\n                    has_state = \"state\" in data\n                    has_detail = \"detail\" in data\n                    if (not has_state) and (not has_detail):\n                        continue\n\n                    state = normalize_state(data.get(\"state\", \"idle\"))\n                    detail = data.get(\"detail\", \"\") or \"\"\n                    # detail 兜底纠偏，确保“工作/休息/报警”能正确落区\n                    state = map_detail_to_state(detail, fallback_state=state)\n\n                    # 防止状态文件久未更新仍停留在 working 态\n                    age = _state_age_seconds(data)\n                    if age is not None and age > STALE_STATE_TTL_SECONDS:\n                        state = \"idle\"\n                        detail = f\"本地状态超过{STALE_STATE_TTL_SECONDS}s未更新，自动回待命\"\n\n                    if VERBOSE:\n                        print(f\"[status-source:file] path={fp} state={state} detail={detail[:60]}\")\n                    return {\"state\": state, \"detail\": detail}\n        except Exception:\n            pass\n\n    # 2) 尝试本地 /status（可能需要鉴权）\n    try:\n        import requests\n        headers = {}\n        if LOCAL_STATUS_TOKEN:\n            headers[\"Authorization\"] = f\"Bearer {LOCAL_STATUS_TOKEN}\"\n        r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5)\n        if r.status_code == 200:\n            data = r.json()\n            state = normalize_state(data.get(\"state\", \"idle\"))\n            detail = data.get(\"detail\", \"\") or \"\"\n            state = map_detail_to_state(detail, fallback_state=state)\n\n            age = _state_age_seconds(data)\n            if age is not None and age > STALE_STATE_TTL_SECONDS:\n                state = \"idle\"\n                detail = f\"本地/status 超过{STALE_STATE_TTL_SECONDS}s未更新，自动回待命\"\n\n            if VERBOSE:\n                print(f\"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}\")\n            return {\"state\": state, \"detail\": detail}\n        # 如果 401，说明需要 token\n        if r.status_code == 401:\n            return {\"state\": \"idle\", \"detail\": \"本地/status需要鉴权（401），请设置 OFFICE_LOCAL_STATUS_TOKEN\"}\n    except Exception:\n        pass\n\n    # 3) 默认 fallback\n    if VERBOSE:\n        print(\"[status-source:fallback] state=idle detail=待命中\")\n    return {\"state\": \"idle\", \"detail\": \"待命中\"}\n\n\ndef do_join(local):\n    import requests\n    payload = {\n        \"name\": local.get(\"agentName\", AGENT_NAME),\n        \"joinKey\": local.get(\"joinKey\", JOIN_KEY),\n        \"state\": \"idle\",\n        \"detail\": \"刚刚加入\"\n    }\n    r = requests.post(f\"{OFFICE_URL}{JOIN_ENDPOINT}\", json=payload, timeout=10)\n    if r.status_code in (200, 201):\n        data = r.json()\n        if data.get(\"ok\"):\n            local[\"joined\"] = True\n            local[\"agentId\"] = data.get(\"agentId\")\n            save_local_state(local)\n            print(f\"✅ 已加入海辛办公室，agentId={local['agentId']}\")\n            return True\n    print(f\"❌ 加入失败：{r.text}\")\n    return False\n\n\ndef do_push(local, status_data):\n    import requests\n    payload = {\n        \"agentId\": local.get(\"agentId\"),\n        \"joinKey\": local.get(\"joinKey\", JOIN_KEY),\n        \"state\": status_data.get(\"state\", \"idle\"),\n        \"detail\": status_data.get(\"detail\", \"\"),\n        \"name\": local.get(\"agentName\", AGENT_NAME)\n    }\n    r = requests.post(f\"{OFFICE_URL}{PUSH_ENDPOINT}\", json=payload, timeout=10)\n    if r.status_code in (200, 201):\n        data = r.json()\n        if data.get(\"ok\"):\n            area = data.get(\"area\", \"breakroom\")\n            print(f\"✅ 状态已同步，当前区域={area}\")\n            return True\n\n    # 403/404：拒绝/移除 → 停止推送\n    if r.status_code in (403, 404):\n        msg = \"\"\n        try:\n            msg = (r.json() or {}).get(\"msg\", \"\")\n        except Exception:\n            msg = r.text\n        print(f\"⚠️  访问拒绝或已移出房间（{r.status_code}），停止推送：{msg}\")\n        local[\"joined\"] = False\n        local[\"agentId\"] = None\n        save_local_state(local)\n        sys.exit(1)\n\n    print(f\"⚠️  推送失败：{r.text}\")\n    return False\n\n\ndef main():\n    local = load_local_state()\n\n    # 先确认配置是否齐全\n    if not JOIN_KEY or not AGENT_NAME:\n        print(\"❌ 请先在脚本开头填入 JOIN_KEY 和 AGENT_NAME\")\n        sys.exit(1)\n\n    # 如果之前没 join，先 join\n    if not local.get(\"joined\") or not local.get(\"agentId\"):\n        ok = do_join(local)\n        if not ok:\n            sys.exit(1)\n\n    # 持续推送\n    print(f\"🚀 开始持续推送状态，间隔={PUSH_INTERVAL_SECONDS}秒\")\n    print(\"🧭 状态逻辑：任务中→工作区；待命/完成→休息区；异常→bug区\")\n    print(\"🔐 若本地 /status 返回 Unauthorized(401)，请设置环境变量：OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL\")\n    try:\n        while True:\n            try:\n                status_data = fetch_local_status()\n                do_push(local, status_data)\n            except Exception as e:\n                print(f\"⚠️  推送异常：{e}\")\n            time.sleep(PUSH_INTERVAL_SECONDS)\n    except KeyboardInterrupt:\n        print(\"\\n👋 停止推送\")\n        sys.exit(0)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "gif_to_spritesheet.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Convert GIF animation to sprite sheet for Phaser\"\"\"\n\nfrom PIL import Image\nimport os\n\ndef gif_to_spritesheet(gif_path, output_path, target_height=64):\n    # Open the GIF\n    gif = Image.open(gif_path)\n    \n    # Get all frames\n    frames = []\n    try:\n        while True:\n            frame = gif.copy().convert('RGBA')\n            # Calculate scale to fit target_height\n            original_width, original_height = frame.size\n            if original_height != target_height:\n                scale = target_height / original_height\n                target_width = int(original_width * scale)\n                frame = frame.resize((target_width, target_height), Image.Resampling.NEAREST)\n            frames.append(frame)\n            gif.seek(gif.tell() + 1)\n    except EOFError:\n        pass\n    \n    if not frames:\n        raise ValueError(\"No frames found in GIF\")\n    \n    # Calculate sprite sheet dimensions\n    frame_width, frame_height = frames[0].size\n    num_frames = len(frames)\n    \n    # Arrange frames in a single row for simplicity\n    sheet_width = frame_width * num_frames\n    sheet_height = frame_height\n    \n    # Create sprite sheet\n    spritesheet = Image.new('RGBA', (sheet_width, sheet_height), (0, 0, 0, 0))\n    \n    # Paste each frame\n    for i, frame in enumerate(frames):\n        x = i * frame_width\n        y = 0\n        spritesheet.paste(frame, (x, y))\n    \n    # Save sprite sheet\n    spritesheet.save(output_path)\n    \n    print(f\"Sprite sheet created: {output_path}\")\n    print(f\"Frames: {num_frames}\")\n    print(f\"Frame size: {frame_width}x{frame_height}\")\n    print(f\"Sprite sheet size: {sheet_width}x{sheet_height}\")\n    \n    return {\n        'num_frames': num_frames,\n        'frame_width': frame_width,\n        'frame_height': frame_height,\n        'sheet_width': sheet_width,\n        'sheet_height': sheet_height\n    }\n\nif __name__ == \"__main__\":\n    import sys\n    \n    if len(sys.argv) < 4:\n        print(\"Usage: python gif_to_spritesheet.py <gif_path> <output_path> <target_height>\")\n        print(\"Example: python gif_to_spritesheet.py star-idle.gif star-idle-spritesheet.png 64\")\n        sys.exit(1)\n    \n    gif_path = sys.argv[1]\n    output_path = sys.argv[2]\n    target_height = int(sys.argv[3])\n    \n    result = gif_to_spritesheet(gif_path, output_path, target_height=target_height)\n    print(\"\\nDone!\")\n"
  },
  {
    "path": "healthcheck.sh",
    "content": "#!/bin/bash\n# Star Office UI Health Check\n# Checks if backend is responding, restarts if not\n\nBACKEND_URL=\"http://127.0.0.1:19000/health\"\nLOG_FILE=\"/root/.openclaw/workspace/star-office-ui/healthcheck.log\"\n\n# Log timestamp\necho \"[$(date '+%Y-%m-%d %H:%M:%S')] Health check starting...\" >> \"$LOG_FILE\"\n\n# Check backend\nif curl -sS \"$BACKEND_URL\" > /dev/null 2>&1; then\n    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] Backend is healthy\" >> \"$LOG_FILE\"\nelse\n    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] Backend is NOT healthy - restarting...\" >> \"$LOG_FILE\"\n    systemctl restart star-office-backend.service\n    echo \"[$(date '+%Y-%m-%d %H:%M:%S')] Backend restarted\" >> \"$LOG_FILE\"\nfi\n"
  },
  {
    "path": "join-keys.sample.json",
    "content": "{\n  \"keys\": [\n    {\n      \"key\": \"ocj_example_team_01\",\n      \"used\": false,\n      \"reusable\": true,\n      \"maxConcurrent\": 3,\n      \"usedBy\": null,\n      \"usedByAgentId\": null,\n      \"usedAt\": null\n    }\n  ]\n}\n"
  },
  {
    "path": "office-agent-push.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\n海辛办公室 - Agent 状态主动推送脚本\n\n用法：\n1. 填入下面的 JOIN_KEY（你从海辛那里拿到的一次性 join key）\n2. 填入 AGENT_NAME（你想要在办公室里显示的名字）\n3. 运行：python office-agent-push.py\n4. 脚本会自动先 join（首次运行），然后每 30s 向海辛办公室推送一次你的当前状态\n\"\"\"\n\nimport json\nimport os\nimport time\nimport sys\nfrom datetime import datetime\n\n# === 你需要填入的信息 ===\nJOIN_KEY = \"\"   # 必填：你的一次性 join key\nAGENT_NAME = \"\" # 必填：你在办公室里的名字\nOFFICE_URL = \"https://office.hyacinth.im\"  # 海辛办公室地址（一般不用改）\n\n# === 推送配置 ===\nPUSH_INTERVAL_SECONDS = 15  # 每隔多少秒推送一次（更实时）\nSTATUS_ENDPOINT = \"/status\"\nJOIN_ENDPOINT = \"/join-agent\"\nPUSH_ENDPOINT = \"/agent-push\"\n\n# 自动状态守护：当本地状态文件不存在或长期不更新时，自动回 idle，避免“假工作中”\nSTALE_STATE_TTL_SECONDS = int(os.environ.get(\"OFFICE_STALE_STATE_TTL\", \"600\"))\n\n# 本地状态存储（记住上次 join 拿到的 agentId）\nSTATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), \"office-agent-state.json\")\n\n# 优先读取本机 OpenClaw 工作区的状态文件（更贴合 AGENTS.md 的工作流）\n# 支持自动发现，减少对方手动配置成本，且避免硬编码绝对路径：\n# - 优先使用环境变量 OPENCLAW_HOME / OPENCLAW_WORKSPACE_DIR\n# - 其次使用当前用户 HOME/.openclaw\n# - 再回落到当前工作目录与脚本所在目录\nOPENCLAW_HOME = os.environ.get(\"OPENCLAW_HOME\") or os.path.join(os.path.expanduser(\"~\"), \".openclaw\")\nOPENCLAW_WORKSPACE_DIR = os.environ.get(\"OPENCLAW_WORKSPACE_DIR\") or os.path.join(OPENCLAW_HOME, \"workspace\")\n\nDEFAULT_STATE_CANDIDATES = [\n    os.path.join(OPENCLAW_WORKSPACE_DIR, \"star-office-ui\", \"state.json\"),\n    os.path.join(OPENCLAW_WORKSPACE_DIR, \"state.json\"),\n    \"/root/.openclaw/workspace/Star-Office-UI/state.json\",  # 当前仓库（大小写精确）\n    \"/root/.openclaw/workspace/star-office-ui/state.json\",  # 历史/兼容路径\n    \"/root/.openclaw/workspace/state.json\",\n    os.path.join(os.getcwd(), \"state.json\"),\n    os.path.join(os.path.dirname(os.path.abspath(__file__)), \"state.json\"),\n]\n\n# 如果对方本地 /status 需要鉴权，可在这里填写 token（或通过环境变量 OFFICE_LOCAL_STATUS_TOKEN）\nLOCAL_STATUS_TOKEN = os.environ.get(\"OFFICE_LOCAL_STATUS_TOKEN\", \"\")\nLOCAL_STATUS_URL = os.environ.get(\"OFFICE_LOCAL_STATUS_URL\", \"http://127.0.0.1:19000/status\")\n# 可选：直接指定本地状态文件路径（最简单方案：绕过 /status 鉴权）\nLOCAL_STATE_FILE = os.environ.get(\"OFFICE_LOCAL_STATE_FILE\", \"\")\nVERBOSE = os.environ.get(\"OFFICE_VERBOSE\", \"0\") in {\"1\", \"true\", \"TRUE\", \"yes\", \"YES\"}\n\n\ndef load_local_state():\n    if os.path.exists(STATE_FILE):\n        try:\n            with open(STATE_FILE, \"r\", encoding=\"utf-8\") as f:\n                return json.load(f)\n        except Exception:\n            pass\n    return {\n        \"agentId\": None,\n        \"joined\": False,\n        \"joinKey\": JOIN_KEY,\n        \"agentName\": AGENT_NAME\n    }\n\n\ndef save_local_state(data):\n    with open(STATE_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(data, f, ensure_ascii=False, indent=2)\n\n\ndef normalize_state(s):\n    \"\"\"兼容不同本地状态词，并映射到办公室识别状态。\"\"\"\n    s = (s or \"\").strip().lower()\n    if s in {\"writing\", \"researching\", \"executing\", \"syncing\", \"error\", \"idle\"}:\n        return s\n    if s in {\"working\", \"busy\", \"write\"}:\n        return \"writing\"\n    if s in {\"run\", \"running\", \"execute\", \"exec\"}:\n        return \"executing\"\n    if s in {\"research\", \"search\"}:\n        return \"researching\"\n    if s in {\"sync\"}:\n        return \"syncing\"\n    return \"idle\"\n\n\ndef map_detail_to_state(detail, fallback_state=\"idle\"):\n    \"\"\"当只有 detail 时，用关键词推断状态（贴近 AGENTS.md 的办公区逻辑）。\"\"\"\n    d = (detail or \"\").lower()\n    if any(k in d for k in [\"报错\", \"error\", \"bug\", \"异常\", \"报警\"]):\n        return \"error\"\n    if any(k in d for k in [\"同步\", \"sync\", \"备份\"]):\n        return \"syncing\"\n    if any(k in d for k in [\"调研\", \"research\", \"搜索\", \"查资料\"]):\n        return \"researching\"\n    if any(k in d for k in [\"执行\", \"run\", \"推进\", \"处理任务\", \"工作中\", \"writing\"]):\n        return \"writing\"\n    if any(k in d for k in [\"待命\", \"休息\", \"idle\", \"完成\", \"done\"]):\n        return \"idle\"\n    return fallback_state\n\n\ndef _state_age_seconds(data):\n    try:\n        ts = (data or {}).get(\"updated_at\")\n        if not ts:\n            return None\n        dt = datetime.fromisoformat(str(ts).replace(\"Z\", \"+00:00\"))\n        if dt.tzinfo is not None:\n            from datetime import timezone\n            return (datetime.now(timezone.utc) - dt.astimezone(timezone.utc)).total_seconds()\n        return (datetime.now() - dt).total_seconds()\n    except Exception:\n        return None\n\n\ndef fetch_local_status():\n    \"\"\"读取本地状态：\n    1) 优先 state.json（符合 AGENTS.md：任务前切 writing，完成后切 idle）\n    2) 其次尝试本地 HTTP /status\n    3) 最后 fallback idle\n\n    额外防抖：如果本地状态更新时间超过 STALE_STATE_TTL_SECONDS，自动视为 idle。\n    \"\"\"\n    # 1) 读本地 state.json（优先读取显式指定路径，其次自动发现）\n    candidate_files = []\n    if LOCAL_STATE_FILE:\n        candidate_files.append(LOCAL_STATE_FILE)\n    for fp in DEFAULT_STATE_CANDIDATES:\n        if fp not in candidate_files:\n            candidate_files.append(fp)\n\n    for fp in candidate_files:\n        try:\n            if fp and os.path.exists(fp):\n                with open(fp, \"r\", encoding=\"utf-8\") as f:\n                    data = json.load(f)\n\n                    # 只接受“状态文件”结构；避免误把 office-agent-state.json（仅缓存 agentId）当状态源\n                    if not isinstance(data, dict):\n                        continue\n                    has_state = \"state\" in data\n                    has_detail = \"detail\" in data\n                    if (not has_state) and (not has_detail):\n                        continue\n\n                    state = normalize_state(data.get(\"state\", \"idle\"))\n                    detail = data.get(\"detail\", \"\") or \"\"\n                    # detail 兜底纠偏，确保“工作/休息/报警”能正确落区\n                    state = map_detail_to_state(detail, fallback_state=state)\n\n                    # 防止状态文件久未更新仍停留在 working 态\n                    age = _state_age_seconds(data)\n                    if age is not None and age > STALE_STATE_TTL_SECONDS:\n                        state = \"idle\"\n                        detail = f\"本地状态超过{STALE_STATE_TTL_SECONDS}s未更新，自动回待命\"\n\n                    if VERBOSE:\n                        print(f\"[status-source:file] path={fp} state={state} detail={detail[:60]}\")\n                    return {\"state\": state, \"detail\": detail}\n        except Exception:\n            pass\n\n    # 2) 尝试本地 /status（可能需要鉴权）\n    try:\n        import requests\n        headers = {}\n        if LOCAL_STATUS_TOKEN:\n            headers[\"Authorization\"] = f\"Bearer {LOCAL_STATUS_TOKEN}\"\n        r = requests.get(LOCAL_STATUS_URL, headers=headers, timeout=5)\n        if r.status_code == 200:\n            data = r.json()\n            state = normalize_state(data.get(\"state\", \"idle\"))\n            detail = data.get(\"detail\", \"\") or \"\"\n            state = map_detail_to_state(detail, fallback_state=state)\n\n            age = _state_age_seconds(data)\n            if age is not None and age > STALE_STATE_TTL_SECONDS:\n                state = \"idle\"\n                detail = f\"本地/status 超过{STALE_STATE_TTL_SECONDS}s未更新，自动回待命\"\n\n            if VERBOSE:\n                print(f\"[status-source:http] url={LOCAL_STATUS_URL} state={state} detail={detail[:60]}\")\n            return {\"state\": state, \"detail\": detail}\n        # 如果 401，说明需要 token\n        if r.status_code == 401:\n            return {\"state\": \"idle\", \"detail\": \"本地/status需要鉴权（401），请设置 OFFICE_LOCAL_STATUS_TOKEN\"}\n    except Exception:\n        pass\n\n    # 3) 默认 fallback\n    if VERBOSE:\n        print(\"[status-source:fallback] state=idle detail=待命中\")\n    return {\"state\": \"idle\", \"detail\": \"待命中\"}\n\n\ndef do_join(local):\n    import requests\n    payload = {\n        \"name\": local.get(\"agentName\", AGENT_NAME),\n        \"joinKey\": local.get(\"joinKey\", JOIN_KEY),\n        \"state\": \"idle\",\n        \"detail\": \"刚刚加入\"\n    }\n    r = requests.post(f\"{OFFICE_URL}{JOIN_ENDPOINT}\", json=payload, timeout=10)\n    if r.status_code in (200, 201):\n        data = r.json()\n        if data.get(\"ok\"):\n            local[\"joined\"] = True\n            local[\"agentId\"] = data.get(\"agentId\")\n            save_local_state(local)\n            print(f\"✅ 已加入海辛办公室，agentId={local['agentId']}\")\n            return True\n    print(f\"❌ 加入失败：{r.text}\")\n    return False\n\n\ndef do_push(local, status_data):\n    import requests\n    payload = {\n        \"agentId\": local.get(\"agentId\"),\n        \"joinKey\": local.get(\"joinKey\", JOIN_KEY),\n        \"state\": status_data.get(\"state\", \"idle\"),\n        \"detail\": status_data.get(\"detail\", \"\"),\n        \"name\": local.get(\"agentName\", AGENT_NAME)\n    }\n    r = requests.post(f\"{OFFICE_URL}{PUSH_ENDPOINT}\", json=payload, timeout=10)\n    if r.status_code in (200, 201):\n        data = r.json()\n        if data.get(\"ok\"):\n            area = data.get(\"area\", \"breakroom\")\n            print(f\"✅ 状态已同步，当前区域={area}\")\n            return True\n\n    # 403/404：拒绝/移除 → 停止推送\n    if r.status_code in (403, 404):\n        msg = \"\"\n        try:\n            msg = (r.json() or {}).get(\"msg\", \"\")\n        except Exception:\n            msg = r.text\n        print(f\"⚠️  访问拒绝或已移出房间（{r.status_code}），停止推送：{msg}\")\n        local[\"joined\"] = False\n        local[\"agentId\"] = None\n        save_local_state(local)\n        sys.exit(1)\n\n    print(f\"⚠️  推送失败：{r.text}\")\n    return False\n\n\ndef main():\n    local = load_local_state()\n\n    # Startup hint for state source and URL (helps with port/state issues, e.g. issue #31)\n    if LOCAL_STATE_FILE:\n        print(f\"State file: {LOCAL_STATE_FILE}\")\n    else:\n        first_existing = next((p for p in DEFAULT_STATE_CANDIDATES if p and os.path.exists(p)), None)\n        if first_existing:\n            print(f\"State file (auto): {first_existing}\")\n        else:\n            print(\"State file: auto-discover (set OFFICE_LOCAL_STATE_FILE if state not found)\")\n    print(f\"Local status URL: {LOCAL_STATUS_URL} (set OFFICE_LOCAL_STATUS_URL if backend uses another port)\")\n\n    # 先确认配置是否齐全\n    if not JOIN_KEY or not AGENT_NAME:\n        print(\"❌ 请先在脚本开头填入 JOIN_KEY 和 AGENT_NAME\")\n        sys.exit(1)\n\n    # 如果之前没 join，先 join\n    if not local.get(\"joined\") or not local.get(\"agentId\"):\n        ok = do_join(local)\n        if not ok:\n            sys.exit(1)\n\n    # 持续推送\n    print(f\"🚀 开始持续推送状态，间隔={PUSH_INTERVAL_SECONDS}秒\")\n    print(\"🧭 状态逻辑：任务中→工作区；待命/完成→休息区；异常→bug区\")\n    print(\"🔐 若本地 /status 返回 Unauthorized(401)，请设置环境变量：OFFICE_LOCAL_STATUS_TOKEN 或 OFFICE_LOCAL_STATUS_URL\")\n    try:\n        while True:\n            try:\n                status_data = fetch_local_status()\n                do_push(local, status_data)\n            except Exception as e:\n                print(f\"⚠️  推送异常：{e}\")\n            time.sleep(PUSH_INTERVAL_SECONDS)\n    except KeyboardInterrupt:\n        print(\"\\n👋 停止推送\")\n        sys.exit(0)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"star-office-ui\"\nversion = \"0.1.0\"\ndescription = \"Star Office UI - Backend State Service\"\nreadme = \"README.md\"\nrequires-python = \">=3.10\"\ndependencies = [\n    \"flask>=3.0.0\",\n]\n"
  },
  {
    "path": "repack_star_working.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Repack star-working spritesheet into a grid to fit GPU max texture sizes.\n\nProblem:\n- Current spritesheet is 44160x144 (192 frames * 230w), too wide for WebGL max texture size on most GPUs.\n- Result: texture upload fails => renders as black rectangle.\n\nThis script repacks frames into rows.\nDefault:\n- frame: 230x144\n- frames: 192\n- cols: 35  -> width 8050\n- rows: ceil(192/35)=6 -> height 864\n\nOutput:\n- frontend/star-working-spritesheet-grid.png\n\nSafe:\n- does NOT delete original file.\n\"\"\"\n\nimport math\nimport os\nfrom PIL import Image\n\nROOT = \"/root/.openclaw/workspace/star-office-ui\"\nIN_PATH = os.path.join(ROOT, \"frontend\", \"star-working-spritesheet.png\")\nOUT_PATH = os.path.join(ROOT, \"frontend\", \"star-working-spritesheet-grid.png\")\n\nFRAME_W = 230\nFRAME_H = 144\nFRAMES = 192\nCOLS = 35\n\n\ndef main():\n    img = Image.open(IN_PATH).convert(\"RGBA\")\n    w, h = img.size\n\n    expected_w = FRAME_W * FRAMES\n    if h != FRAME_H or w < expected_w:\n        raise SystemExit(f\"Unexpected input size {img.size}, expected height={FRAME_H}, width>={expected_w}\")\n\n    rows = math.ceil(FRAMES / COLS)\n    out_w = FRAME_W * COLS\n    out_h = FRAME_H * rows\n\n    out = Image.new(\"RGBA\", (out_w, out_h), (0, 0, 0, 0))\n\n    for i in range(FRAMES):\n        src_x0 = i * FRAME_W\n        src_y0 = 0\n        frame = img.crop((src_x0, src_y0, src_x0 + FRAME_W, src_y0 + FRAME_H))\n\n        r = i // COLS\n        c = i % COLS\n        dst_x0 = c * FRAME_W\n        dst_y0 = r * FRAME_H\n        out.paste(frame, (dst_x0, dst_y0))\n\n    out.save(OUT_PATH)\n\n    orig_size = os.path.getsize(IN_PATH)\n    new_size = os.path.getsize(OUT_PATH)\n    print(f\"Wrote: {OUT_PATH}\")\n    print(f\"Input size: {w}x{h}  ({orig_size/1024/1024:.2f} MB)\")\n    print(f\"Output size: {out_w}x{out_h} ({new_size/1024/1024:.2f} MB)\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "resize_map.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Resize office map by SHORT EDGE scaling (keep aspect ratio, no stretching/cropping)\"\"\"\n\nfrom PIL import Image\n\ndef resize_map(input_path, output_path, target_short_edge=600):\n    im = Image.open(input_path)\n    original_width, original_height = im.size\n    \n    # Determine which is the SHORT edge\n    if original_width < original_height:\n        short_edge, long_edge = original_width, original_height\n        is_width_short = True\n    else:\n        short_edge, long_edge = original_height, original_width\n        is_width_short = False\n    \n    # Calculate scale based on SHORT edge\n    scale = target_short_edge / short_edge\n    \n    # Compute new dimensions\n    if is_width_short:\n        new_width = target_short_edge\n        new_height = int(long_edge * scale)\n    else:\n        new_width = int(long_edge * scale)\n        new_height = target_short_edge\n    \n    # Resize (use LANCZOS for high quality)\n    im_resized = im.resize((new_width, new_height), Image.Resampling.LANCZOS)\n    \n    im_resized.save(output_path)\n    print(f\"Resized map saved: {output_path}\")\n    print(f\"Original size: {original_width}x{original_height}\")\n    print(f\"Resized size: {new_width}x{new_height}\")\n    print(f\"Short edge scale: {scale:.2f}x\")\n\nif __name__ == \"__main__\":\n    input_path = \"/root/.openclaw/media/inbound/6b352c7d-f09f-4dd7-9916-a312fb60122b.png\"\n    output_path = \"/root/.openclaw/workspace/star-office-ui/frontend/office_bg.png\"\n    resize_map(input_path, output_path, target_short_edge=720)\n"
  },
  {
    "path": "runtime-config.sample.json",
    "content": "{\n  \"gemini_api_key\": \"YOUR_GEMINI_API_KEY\",\n  \"gemini_model\": \"nanobanana-pro\"\n}\n"
  },
  {
    "path": "scripts/gemini_image_generate.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Gemini Image Generate - CLI for Star Office UI background generation.\n\nCalls Google's Gemini API to generate images, with optional reference image\nfor style transfer / layout preservation.\n\nExpected interface (called by Star Office UI backend):\n  python gemini_image_generate.py \\\n    --prompt \"...\" \\\n    --model <model_name> \\\n    --out-dir /tmp/xxx \\\n    --cleanup \\\n    [--aspect-ratio 16:9] \\\n    [--reference-image /path/to/ref.webp]\n\nEnvironment:\n  GEMINI_API_KEY  - Google AI API key (required)\n  GEMINI_MODEL    - override model name (optional, --model takes precedence)\n\nOutput (last line of stdout):\n  {\"files\": [\"/tmp/xxx/generated_0.png\"]}\n\"\"\"\n\nimport argparse\nimport base64\nimport json\nimport mimetypes\nimport os\nimport sys\nimport tempfile\nimport shutil\nfrom pathlib import Path\n\ntry:\n    from google import genai\n    from google.genai import types\n    HAS_GENAI = True\nexcept ImportError:\n    HAS_GENAI = False\n\n\ndef detect_mime(path: str) -> str:\n    mt, _ = mimetypes.guess_type(path)\n    if mt:\n        return mt\n    ext = os.path.splitext(path)[1].lower()\n    return {\n        \".png\": \"image/png\",\n        \".jpg\": \"image/jpeg\",\n        \".jpeg\": \"image/jpeg\",\n        \".webp\": \"image/webp\",\n        \".gif\": \"image/gif\",\n    }.get(ext, \"image/png\")\n\n\ndef main():\n    parser = argparse.ArgumentParser(description=\"Generate image via Gemini API\")\n    parser.add_argument(\"--prompt\", required=True, help=\"Generation prompt\")\n    parser.add_argument(\"--model\", default=\"\", help=\"Model name\")\n    parser.add_argument(\"--out-dir\", required=True, help=\"Output directory\")\n    parser.add_argument(\"--cleanup\", action=\"store_true\", help=\"(ignored, kept for compat)\")\n    parser.add_argument(\"--aspect-ratio\", default=\"\", help=\"Aspect ratio hint (e.g. 16:9)\")\n    parser.add_argument(\"--reference-image\", default=\"\", help=\"Reference image path\")\n    args = parser.parse_args()\n\n    # Resolve API key\n    api_key = os.environ.get(\"GEMINI_API_KEY\", \"\").strip()\n    if not api_key:\n        api_key = os.environ.get(\"GOOGLE_API_KEY\", \"\").strip()\n    if not api_key:\n        print(\"ERROR: GEMINI_API_KEY or GOOGLE_API_KEY not set\", file=sys.stderr)\n        sys.exit(1)\n\n    # Resolve model - env var GEMINI_MODEL overrides --model flag\n    model = os.environ.get(\"GEMINI_MODEL\", \"\").strip() or args.model.strip()\n    if not model:\n        model = \"gemini-2.0-flash-exp\"\n\n    # Ensure output directory\n    out_dir = args.out_dir\n    os.makedirs(out_dir, exist_ok=True)\n\n    if not HAS_GENAI:\n        print(\"ERROR: google-genai package not installed\", file=sys.stderr)\n        sys.exit(1)\n\n    # Initialize client\n    client = genai.Client(api_key=api_key)\n\n    # Build prompt parts\n    contents = []\n\n    # Add reference image if provided\n    if args.reference_image and os.path.exists(args.reference_image):\n        ref_path = args.reference_image\n        mime = detect_mime(ref_path)\n        with open(ref_path, \"rb\") as f:\n            ref_data = f.read()\n        contents.append(\n            types.Part.from_bytes(data=ref_data, mime_type=mime)\n        )\n\n    # Add text prompt\n    prompt_text = args.prompt\n    if args.aspect_ratio:\n        prompt_text += f\"\\nTarget aspect ratio: {args.aspect_ratio}.\"\n    contents.append(prompt_text)\n\n    # Configure generation\n    generate_config = types.GenerateContentConfig(\n        response_modalities=[\"TEXT\", \"IMAGE\"],\n    )\n\n    try:\n        response = client.models.generate_content(\n            model=model,\n            contents=contents,\n            config=generate_config,\n        )\n    except Exception as e:\n        err_msg = str(e)\n        print(f\"ERROR: {err_msg}\", file=sys.stderr)\n        sys.exit(1)\n\n    # Extract generated images\n    output_files = []\n    idx = 0\n\n    if response.candidates:\n        for candidate in response.candidates:\n            if not candidate.content or not candidate.content.parts:\n                continue\n            for part in candidate.content.parts:\n                if part.inline_data and part.inline_data.mime_type and part.inline_data.mime_type.startswith(\"image/\"):\n                    # Determine extension from mime\n                    mime = part.inline_data.mime_type\n                    ext_map = {\n                        \"image/png\": \".png\",\n                        \"image/jpeg\": \".jpg\",\n                        \"image/webp\": \".webp\",\n                    }\n                    ext = ext_map.get(mime, \".png\")\n                    out_path = os.path.join(out_dir, f\"generated_{idx}{ext}\")\n                    with open(out_path, \"wb\") as f:\n                        f.write(part.inline_data.data)\n                    output_files.append(out_path)\n                    idx += 1\n\n    if not output_files:\n        # Check if there's text response with error info\n        text_parts = []\n        if response.candidates:\n            for candidate in response.candidates:\n                if candidate.content and candidate.content.parts:\n                    for part in candidate.content.parts:\n                        if part.text:\n                            text_parts.append(part.text)\n        if text_parts:\n            print(f\"ERROR: No image generated. Model response: {' '.join(text_parts)[:500]}\", file=sys.stderr)\n        else:\n            print(\"ERROR: No image generated and no text response\", file=sys.stderr)\n        sys.exit(1)\n\n    # Output result as JSON (backend reads the last line)\n    result = {\"files\": output_files}\n    print(json.dumps(result))\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/security_check.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Star Office UI security preflight checker (non-destructive).\n\nChecks:\n- weak/default secrets in env\n- risky tracked files in git index\n- known API key patterns in tracked files\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nimport re\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nROOT = Path(__file__).resolve().parent.parent\n\n\ndef run(cmd: list[str]) -> tuple[int, str, str]:\n    p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True)\n    return p.returncode, p.stdout.strip(), p.stderr.strip()\n\n\ndef is_strong_secret(v: str) -> bool:\n    if not v:\n        return False\n    s = v.strip()\n    if len(s) < 24:\n        return False\n    low = s.lower()\n    for token in (\"change-me\", \"default\", \"example\", \"test\", \"dev\"):\n        if token in low:\n            return False\n    return True\n\n\ndef is_strong_pass(v: str) -> bool:\n    if not v:\n        return False\n    s = v.strip()\n    if s == \"1234\":\n        return False\n    return len(s) >= 8\n\n\ndef tracked_files() -> list[str]:\n    code, out, _ = run([\"git\", \"ls-files\"])\n    if code != 0:\n        return []\n    return [x for x in out.splitlines() if x.strip()]\n\n\ndef file_has_secret_pattern(path: Path) -> list[str]:\n    hits: list[str] = []\n    try:\n        text = path.read_text(encoding=\"utf-8\", errors=\"ignore\")\n    except Exception:\n        return hits\n\n    patterns = [\n        (r\"AIza[0-9A-Za-z\\-_]{20,}\", \"Google/Gemini API key-like token\"),\n        (r\"sk-[A-Za-z0-9]{16,}\", \"Generic sk-* token\"),\n        (r\"AKIA[0-9A-Z]{16}\", \"AWS access key-like token\"),\n    ]\n    for pat, label in patterns:\n        if re.search(pat, text):\n            hits.append(label)\n    return hits\n\n\ndef main() -> int:\n    print(\"[security-check] Star Office UI preflight\")\n\n    failures: list[str] = []\n    warnings: list[str] = []\n\n    env_mode = (os.getenv(\"STAR_OFFICE_ENV\") or os.getenv(\"FLASK_ENV\") or \"\").strip().lower()\n    in_prod = env_mode in {\"prod\", \"production\"}\n\n    secret = os.getenv(\"FLASK_SECRET_KEY\") or os.getenv(\"STAR_OFFICE_SECRET\") or \"\"\n    drawer_pass = os.getenv(\"ASSET_DRAWER_PASS\") or \"\"\n\n    if in_prod:\n        if not is_strong_secret(secret):\n            failures.append(\"Weak/missing FLASK_SECRET_KEY (or STAR_OFFICE_SECRET) in production\")\n        if not is_strong_pass(drawer_pass):\n            failures.append(\"Weak/missing ASSET_DRAWER_PASS in production\")\n    else:\n        if not secret:\n            warnings.append(\"FLASK_SECRET_KEY not set (ok for local dev, not for production)\")\n        if not drawer_pass:\n            warnings.append(\"ASSET_DRAWER_PASS not set (defaults may be unsafe for public exposure)\")\n\n    tracked = tracked_files()\n    risky_tracked = [\n        \"runtime-config.json\",\n        \"join-keys.json\",\n        \"office-agent-state.json\",\n    ]\n    for f in risky_tracked:\n        if f in tracked:\n            failures.append(f\"Risky runtime file is tracked by git: {f}\")\n\n    # scan tracked text-ish files for common secret patterns\n    for rel in tracked:\n        if rel.startswith(\".git/\"):\n            continue\n        p = ROOT / rel\n        if not p.exists() or p.is_dir():\n            continue\n        if p.stat().st_size > 2_000_000:\n            continue\n        hits = file_has_secret_pattern(p)\n        for h in hits:\n            failures.append(f\"Potential secret pattern in tracked file: {rel} ({h})\")\n\n    if warnings:\n        print(\"\\nWarnings:\")\n        for w in warnings:\n            print(f\"  - {w}\")\n\n    if failures:\n        print(\"\\nFAIL:\")\n        for f in failures:\n            print(f\"  - {f}\")\n        print(\"\\nResult: FAILED\")\n        return 1\n\n    print(\"\\nResult: OK\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "scripts/smoke_test.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Star Office UI smoke test (non-destructive).\n\nUsage:\n  python3 scripts/smoke_test.py --base-url http://127.0.0.1:19000\n\nOptional env:\n  SMOKE_AUTH_BEARER=xxxx   # if your gateway/proxy requires bearer auth\n\"\"\"\n\nfrom __future__ import annotations\n\nimport argparse\nimport json\nimport os\nimport sys\nimport urllib.error\nimport urllib.request\n\n\nREQUIRED_ENDPOINTS = [\n    (\"GET\", \"/\", 200),\n    (\"GET\", \"/health\", 200),\n    (\"GET\", \"/status\", 200),\n    (\"GET\", \"/agents\", 200),\n    (\"GET\", \"/yesterday-memo\", 200),\n]\n\n\ndef req(method: str, url: str, body: dict | None = None, token: str = \"\") -> tuple[int, str]:\n    data = None\n    headers = {}\n    if token:\n        headers[\"Authorization\"] = f\"Bearer {token}\"\n    if body is not None:\n        data = json.dumps(body).encode(\"utf-8\")\n        headers[\"Content-Type\"] = \"application/json\"\n\n    r = urllib.request.Request(url=url, method=method, data=data, headers=headers)\n    try:\n        with urllib.request.urlopen(r, timeout=8) as resp:\n            raw = resp.read().decode(\"utf-8\", errors=\"ignore\")\n            return resp.status, raw\n    except urllib.error.HTTPError as e:\n        raw = e.read().decode(\"utf-8\", errors=\"ignore\") if hasattr(e, \"read\") else str(e)\n        return e.code, raw\n    except Exception as e:\n        return 0, str(e)\n\n\ndef main() -> int:\n    ap = argparse.ArgumentParser()\n    ap.add_argument(\"--base-url\", default=\"http://127.0.0.1:19000\", help=\"Base URL of Star Office UI service\")\n    args = ap.parse_args()\n\n    base = args.base_url.rstrip(\"/\")\n    token = os.getenv(\"SMOKE_AUTH_BEARER\", \"\").strip()\n\n    failures: list[str] = []\n    print(f\"[smoke] base={base}\")\n\n    for method, path, expected in REQUIRED_ENDPOINTS:\n        code, body = req(method, base + path, token=token)\n        if code != expected:\n            failures.append(f\"{method} {path}: expected {expected}, got {code}, body={body[:200]}\")\n        else:\n            print(f\"  OK  {method} {path} -> {code}\")\n\n    # non-destructive state update probe\n    code, body = req(\"POST\", base + \"/set_state\", {\"state\": \"idle\", \"detail\": \"smoke-check\"}, token=token)\n    if code != 200:\n        failures.append(f\"POST /set_state failed: {code}, body={body[:200]}\")\n    else:\n        print(\"  OK  POST /set_state -> 200\")\n\n    if failures:\n        print(\"\\n[smoke] FAIL\")\n        for f in failures:\n            print(\" -\", f)\n        return 1\n\n    print(\"\\n[smoke] PASS\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "set_state.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Update Star Office UI state (for testing or agent-driven sync).\n\nFor automatic state sync from OpenClaw: add a rule in your agent SOUL.md or AGENTS.md:\n  Before starting a task: run `python3 set_state.py writing \"doing XYZ\"`.\n  After finishing: run `python3 set_state.py idle \"ready\"`.\nThe office UI reads state from the same state.json this script writes.\n\"\"\"\n\nimport json\nimport os\nimport sys\nfrom datetime import datetime\n\nSTATE_FILE = os.environ.get(\n    \"STAR_OFFICE_STATE_FILE\",\n    os.path.join(os.path.dirname(os.path.abspath(__file__)), \"state.json\"),\n)\n\nVALID_STATES = [\n    \"idle\",\n    \"writing\",\n    \"receiving\",\n    \"replying\",\n    \"researching\",\n    \"executing\",\n    \"syncing\",\n    \"error\"\n]\n\ndef load_state():\n    if os.path.exists(STATE_FILE):\n        with open(STATE_FILE, \"r\", encoding=\"utf-8\") as f:\n            return json.load(f)\n    return {\n        \"state\": \"idle\",\n        \"detail\": \"待命中...\",\n        \"progress\": 0,\n        \"updated_at\": datetime.now().isoformat()\n    }\n\ndef save_state(state):\n    with open(STATE_FILE, \"w\", encoding=\"utf-8\") as f:\n        json.dump(state, f, ensure_ascii=False, indent=2)\n\nif __name__ == \"__main__\":\n    if len(sys.argv) < 2:\n        print(\"用法: python set_state.py <state> [detail]\")\n        print(f\"状态选项: {', '.join(VALID_STATES)}\")\n        print(\"\\n例子:\")\n        print(\"  python set_state.py idle\")\n        print(\"  python set_state.py researching \\\"在查 Godot MCP...\\\"\")\n        print(\"  python set_state.py writing \\\"在写热点日报模板...\\\"\")\n        sys.exit(1)\n    \n    state_name = sys.argv[1]\n    detail = sys.argv[2] if len(sys.argv) > 2 else \"\"\n    \n    if state_name not in VALID_STATES:\n        print(f\"无效状态: {state_name}\")\n        print(f\"有效选项: {', '.join(VALID_STATES)}\")\n        sys.exit(1)\n    \n    state = load_state()\n    state[\"state\"] = state_name\n    state[\"detail\"] = detail\n    state[\"updated_at\"] = datetime.now().isoformat()\n    \n    save_state(state)\n    print(f\"状态已更新: {state_name} - {detail}\")\n"
  },
  {
    "path": "state.sample.json",
    "content": "{\n  \"state\": \"idle\",\n  \"detail\": \"Waiting...\",\n  \"progress\": 0,\n  \"updated_at\": \"2026-02-26T00:00:00\"\n}\n"
  },
  {
    "path": "webp_to_spritesheet.py",
    "content": "#!/usr/bin/env python3\n\"\"\"Convert an animated WebP to a horizontal spritesheet PNG.\n\nNotes:\n- Phaser's built-in loader doesn't support animated WebP directly.\n- We convert frames into a spritesheet.\n- Output: <name>-spritesheet.png\n\"\"\"\n\nimport os\nfrom PIL import Image\n\n\ndef webp_to_spritesheet(in_path: str, out_path: str, frame_w: int, frame_h: int, max_frames: int | None = None):\n    im = Image.open(in_path)\n    n = getattr(im, 'n_frames', 1)\n    if max_frames:\n        n = min(n, max_frames)\n\n    sheet = Image.new('RGBA', (frame_w * n, frame_h), (0, 0, 0, 0))\n\n    for i in range(n):\n        im.seek(i)\n        fr = im.convert('RGBA')\n        if fr.size != (frame_w, frame_h):\n            fr = fr.resize((frame_w, frame_h), Image.NEAREST)\n        sheet.paste(fr, (i * frame_w, 0))\n\n    sheet.save(out_path)\n    return n\n\n\ndef main():\n    import argparse\n    ap = argparse.ArgumentParser()\n    ap.add_argument('in_path')\n    ap.add_argument('out_path')\n    ap.add_argument('--w', type=int, required=True)\n    ap.add_argument('--h', type=int, required=True)\n    ap.add_argument('--max', type=int, default=None)\n    args = ap.parse_args()\n\n    n = webp_to_spritesheet(args.in_path, args.out_path, args.w, args.h, args.max)\n    print(f\"Wrote {args.out_path} with {n} frames\")\n\n\nif __name__ == '__main__':\n    main()\n"
  }
]