{"id":50791072,"url":"https://github.com/gtoxlili/phantom-cipher","last_synced_at":"2026-06-12T11:01:27.248Z","repository":{"id":354432347,"uuid":"1223458237","full_name":"gtoxlili/phantom-cipher","owner":"gtoxlili","description":"达芬奇密码 · 浏览器即开即玩的 2-4 人密码推理桌游 · 4 字符房间码拉朋友入局 · 怪盗 / Persona 5 视觉风格 · 自托管开源","archived":false,"fork":false,"pushed_at":"2026-05-06T04:33:40.000Z","size":2333,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-06T04:34:13.306Z","etag":null,"topics":["axum","bilingual","board-game","browser-game","card-game","chinese","coda","da-vinci-code","davinci-code","deduction-game","multiplayer-game","persona-5","pwa","realtime","rust","self-hosted","solidjs","sqlite","web-game","websocket"],"latest_commit_sha":null,"homepage":"https://cipher.gtio.work/","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"gpl-3.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/gtoxlili.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-28T10:45:52.000Z","updated_at":"2026-05-04T09:29:30.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/gtoxlili/phantom-cipher","commit_stats":null,"previous_names":["gtoxlili/phantom-cipher"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/gtoxlili/phantom-cipher","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gtoxlili%2Fphantom-cipher","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gtoxlili%2Fphantom-cipher/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gtoxlili%2Fphantom-cipher/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gtoxlili%2Fphantom-cipher/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/gtoxlili","download_url":"https://codeload.github.com/gtoxlili/phantom-cipher/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/gtoxlili%2Fphantom-cipher/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34240817,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-12T02:00:06.859Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["axum","bilingual","board-game","browser-game","card-game","chinese","coda","da-vinci-code","davinci-code","deduction-game","multiplayer-game","persona-5","pwa","realtime","rust","self-hosted","solidjs","sqlite","web-game","websocket"],"created_at":"2026-06-12T11:01:16.106Z","updated_at":"2026-06-12T11:01:27.227Z","avatar_url":"https://github.com/gtoxlili.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 怪盗密码 · Phantom Cipher\n\n浏览器即开即玩的多人**达芬奇密码**（*Da Vinci Code* / *Coda*）在线版。开局生成 4 字符房间码，分享给朋友就能开战；2-4 人对局，单局 5-30 分钟。\n\n视觉走 **Persona 5 / 怪盗团**风格——红黑骨架 + 倾斜衬线 + 半调网点 + 漢字标语。\n\n\u003e **在线试玩**: [cipher.gtio.work](https://cipher.gtio.work) · [English version](./README.en.md)\n\n![cover](frontend/public/og-image.png)\n\n---\n\n## 玩什么\n\n24 块木牌：**黑色 0-11 + 白色 0-11 + 一对赖子**（黑白各一）。\n\n发牌后每人手里 3-4 张，按从小到大排好（同数字时黑色在前），扣着面对所有人。每回合：\n\n1. **抽**——从两堆牌堆挑一堆抽一张（如果是赖子还要选个位置塞进手里）\n2. **猜**——指定某个对手的某张牌，报一个数字\n   - 猜中 → 这张牌翻开摆在桌上，**继续**或**收手**自己选\n   - 猜错 → 你刚抽的那张被强制亮明，回合结束让位\n3. 谁的手牌全被翻明就出局。最后只剩一个人 = 胜利。\n\n赖子不带数字，你猜的时候报\"-\"代表\"我赌它就是赖子\"。\n\n---\n\n## 技术栈\n\n| 层 | 实现 |\n| --- | --- |\n| 后端 | Rust + axum + tokio + rusqlite (bundled) |\n| 协议 | WebSocket，二进制帧用 MessagePack 编码 |\n| 持久化 | 嵌入式 SQLite，WAL 模式，schema 见 `backend/src/db.rs` |\n| 前端 | Solid.js + Vite + Panda CSS + solid-motionone |\n| 字体 | @fontsource 自托管（Bebas Neue / Inter / Oswald），中文走系统字体（PingFang / 微软雅黑 / 思源黑体） |\n| 部署 | distroless/cc 单二进制 ~5 MB，整镜像 ~40 MB |\n\n### 关键架构选择\n\n- **状态机就是一个 `Game` struct + `Mutex`**——`backend/src/game.rs` 是纯同步的 Rust，没有 actor、没有 channel；动作 / WS / 持久化分别是它外层的几个薄壳。\n- **广播去重**：每次状态变更只 msgpack 序列化一次，结果包成 `Arc\u003cBytes\u003e` 扇出给所有订阅者；私有手牌按人单独编码。\n- **断线宽限**：WebSocket 关闭后服务端不立即 forfeit，给 30 秒等重连。同 pid 重新连进来取消定时器；超时则走 `Game::leave` 的同一条退出路径。\n- **闲置房间清扫**：每 5 分钟扫一次，按相位 TTL（`waiting`/`ended` 1h，进行中 6h），有订阅者的房间自动跳过。\n- **WS 自动重连**：客户端指数回退（800ms → 30s）+ ±25% jitter。1000/1001 干净关闭跳过；其他 close code 一律重连。\n- **应用层心跳**：客户端 25s 一次空文本帧，扛 NAT / 反代 idle 超时。\n- **SPA fallback 注入**：Rust 端按请求 Host 把 `og:image` 改写成绝对 URL，按 URL 把 `\u003ctitle\u003e` 写成\"入局 · {房间码}\"，让分享链接的卡片预览能看出哪个房间。\n\n---\n\n## 快速试玩\n\n```bash\ndocker run -d --name phantom-cipher \\\n  --restart unless-stopped \\\n  --init \\\n  -p 33285:3000 \\\n  --memory 96m \\\n  --ulimit nofile=65535:65535 \\\n  -v phantom-cipher-data:/app/data \\\n  ghcr.io/gtoxlili/phantom-cipher:latest\n```\n\n打开 `http://localhost:33285`，开局，把 4 字符码丢给朋友。\n\n### 环境变量\n\n| 变量 | 默认 | 用途 |\n| --- | --- | --- |\n| `PORT` | 3000 | 容器内监听端口 |\n| `DB_PATH` | `/app/data/phantom.db` | SQLite 文件路径 |\n| `FRONTEND_DIST` | `/app/public` | 静态前端 dist |\n| `RUST_LOG` | `info,phantom_cipher=info` | tracing 过滤 |\n| `WX_APPID` | *(空)* | 微信小程序 AppID。空则 `/api/wx/login` 503，小程序端自动 fallback 到本地 UUID |\n| `WX_SECRET` | *(空)* | 微信小程序 AppSecret，跟 `WX_APPID` 一起配齐才启用 openid 认证 |\n\n\u003e 微信服务端代理还有几条端点（订阅消息推送 / 动态分享卡 / URL Link / 配额运维）后端实现了但还没接到客户端，详见 [docs/wechat-server-apis.md](./docs/wechat-server-apis.md)。\n\n### 资源占用参考\n\n- **空载**：~3 MB RSS / 0% CPU\n- **100 房间在玩**：~60 MB RSS / \u003c10% 单核\n- 1 vCPU + 96m 内存的小机器能稳定承载 200-300 并发房间\n\n---\n\n## 部署到自己的机器\n\n镜像已经构建好双架构（linux/amd64 + linux/arm64），CI 自动产出 `:latest` 和 `:sha-\u003cshort\u003e` 两个 tag。\n\n如果前面挂 nginx 反代（推荐），关键 location 块：\n\n```nginx\n# WebSocket 升级 + 不缓冲 + 长超时\nlocation ~ ^/api/room/[A-Z0-9]+/ws$ {\n    proxy_pass http://localhost:33285;\n    proxy_http_version 1.1;\n    proxy_set_header Upgrade $http_upgrade;\n    proxy_set_header Connection $connection_upgrade;\n    proxy_set_header Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    proxy_buffering off;\n    proxy_read_timeout 3600s;\n    proxy_send_timeout 3600s;\n}\n\n# 其他全部透传，让 Rust 端自己决定 Cache-Control\nlocation / {\n    proxy_pass http://localhost:33285;\n    proxy_http_version 1.1;\n    proxy_set_header Host $host;\n    proxy_set_header X-Forwarded-Proto $scheme;\n    # nginx 出口压缩，让上游不要再压一遍\n    proxy_set_header Accept-Encoding \"\";\n}\n```\n\n需要 `map $http_upgrade $connection_upgrade { default upgrade; '' close; }` 在 `http { }` 里定义好。\n\n---\n\n## 本地开发\n\n需要 **Rust 1.95+**、**Node 24+**、**pnpm 10+**。\n\n```bash\n# 后端\ncd backend\ncargo run            # 监听 0.0.0.0:3000\n\n# 前端（另开一个终端）\ncd frontend\npnpm install\npnpm dev             # 监听 5173，/api 反代到 :3000\n\n# 跑测试\ncd backend \u0026\u0026 cargo test\n```\n\n前端 dev server 内置了 Vite 代理，把 `/api/*` 转发到后端 :3000；浏览器打开 `http://localhost:5173` 即可。生产构建：\n\n```bash\ncd frontend \u0026\u0026 pnpm build      # → frontend/dist/\ncd backend \u0026\u0026 cargo build --release\nFRONTEND_DIST=../frontend/dist ./backend/target/release/phantom-cipher\n```\n\n---\n\n## 仓库结构\n\n```\nphantom-cipher/\n├── backend/               Rust + axum\n│   ├── src/\n│   │   ├── main.rs        启动入口（env 配置 + 路由组装）\n│   │   ├── game.rs        规则引擎（21 个单测覆盖）\n│   │   ├── store.rs       房间注册表 + 广播 + Store::mutate\n│   │   ├── db.rs          SQLite 持久化 + 归档\n│   │   ├── routes/\n│   │   │   ├── actions.rs REST 动作（join/start/draw/...）\n│   │   │   ├── ws.rs      WebSocket 升级 + 读写循环\n│   │   │   └── stats.rs   /api/stats\n│   │   ├── spa.rs         SPA fallback + HTML 注入\n│   │   ├── sweeper.rs     闲置房间清扫\n│   │   ├── disconnect.rs  AFK forfeit 定时器\n│   │   └── types.rs       wire 协议类型\n│   └── Cargo.toml\n├── frontend/              Solid.js + Vite\n│   ├── src/\n│   │   ├── main.tsx       入口 + 路由 + ErrorBoundary\n│   │   ├── routes/        Home + Room\n│   │   ├── components/    Sketch / Tile / room/*\n│   │   ├── stores/        session / game / notifications\n│   │   └── lib/           api（REST 客户端） + ws（WS+msgpack）\n│   ├── public/            PWA 图标 / og-image / manifest\n│   └── package.json\n├── Dockerfile             三段式构建（Node + Rust + distroless/cc）\n└── .github/workflows/\n    └── docker-publish.yml CI（main 推送 → 双架构镜像）\n```\n\n---\n\n## 协议字段\n\nWebSocket 帧 = MessagePack 编码的 `{ t, d }` 对象，`t` 是单字符 tag：\n\n| `t` | `d` 类型 | 含义 |\n| --- | --- | --- |\n| `p` | `PublicGameState` | 公共状态（所有人手牌的位置 + 显隐 + 当前回合） |\n| `v` | `PrivateState` | 你自己的手牌（含未亮明的数字） |\n| `r` | `RevealInfo` | 翻牌结果（猜测的目标 + 命中与否） |\n\nREST 动作信封：`{ ok: true }` / `{ ok: false, error: \"...\" }`。错误信息直接是中文，前端 toast 出来。\n\n详细类型见 `backend/src/types.rs` 和 `frontend/src/types.ts`。\n\n---\n\n## 玩家数据\n\n`/api/stats` 返回 `{ totals, leaderboard, recent }`：累计对局数 / 累计玩家数 / 进行中房间数、按胜场排序的前 N、最近 N 局的简略信息。`Cache-Control: max-age=10`，前端做排行榜时直接拉这一个就够。\n\n玩家身份用 **FingerprintJS** 算的浏览器指纹（visitorId），首次算完后 cache 在 **localStorage** 里。同一台机器同一个浏览器里——不管开几个标签页、关多少次 tab、清不清 cookie——排行榜上记的都是同一个玩家。\n\n老实说几个边界：\n\n- **共享设备会撞**——网吧 / 家里两个人轮流玩一台机器的话指纹相同\n- **反指纹浏览器拒绝**——Brave / Firefox RFP / 部分 Safari 模式会让指纹库拿到一个退化结果，这种情况会自动 fallback 成 `crypto.randomUUID()` + localStorage（行为退化成\"会跟踪当前浏览器，但浏览器之间各自独立\"）\n- **跨浏览器 / 跨设备认不出来**——指纹库不可能解决这个，要严格的多设备身份得做账号系统\n\n**没有 cookie，没有账号**，整个鉴权信任就靠这个 visitorId——服务端拿到啥就认啥。Server-Action 安全模型仍然是\"拿到 pid 的人 = 这个 pid 对应的玩家\"，跟原版同。\n\n---\n\n## License\n\nGPL-3.0。游戏规则借自 *Da Vinci Code*（若杉栄治 / バンソウ），本仓库只是软件实现。\n\n视觉灵感来自 *Persona 5*（ATLUS）的 UI 语言；色板和排版自己重画的，不直接复用任何素材。\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgtoxlili%2Fphantom-cipher","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fgtoxlili%2Fphantom-cipher","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fgtoxlili%2Fphantom-cipher/lists"}