一个海龟汤网站。玩家看汤面、向 AI 主持人提问,主持人只回答「是 / 不是 / 是也不是 / 无关 / 不知道」,场景会随着推理的进展发生变化。
在线体验:https://soup.bu2z.de
本项目认可并支持 LINUX DO 社区。欢迎在 LINUX DO 交流反馈、分享你的推理过程(请注意不要直接剧透汤底)。
npm install
cp .env.example .env # 填入 LLM_API_KEY
npm run dev # 前端 http://localhost:5173 ,后端 :8787生产环境:
npm run build && npm start # 单进程同时托管 dist/ 和 /api整个服务是一个 Node 进程(托管前端 + API),会话在内存里,每分钟及退出时落盘到 data/sessions.json,发版重启不丢局。单机部署不需要数据库或 Redis。
服务器上需要能访问 api.deepseek.com。字体已经本地托管,页面不依赖 Google Fonts 等境外资源。
方式一:Docker
cp .env.example .env # 填 LLM_API_KEY
docker compose up -d --build # 监听 127.0.0.1:8787国内服务器拉不到 Docker Hub 时,给 Docker 配一个镜像加速源,或者用方式二。
方式二:直接跑 Node + 自动发版(线上用的就是这种)
服务器目录结构:
/opt/haiguitang/
node/ Node 22(只给本服务用)
bin/deploy 发版脚本(deploy/server/deploy.sh)
releases/<时间>/ 每次发版一个目录,保留最近 5 个
current -> releases/... systemd 从这里启动
shared/.env LLM_API_KEY 等
shared/data/ 对局落盘
- 自动发版:push 到
main后,GitHub Actions(.github/workflows/deploy.yml)会依次执行:- 类型检查 + 构建,失败就停下,不会发到服务器;
- 用
deploy/pack.sh打出发布包,通过 SSH 送进服务器的bin/deploy; - 服务器解包、装生产依赖、原子切换
current、重启; - 健康检查不过就自动回滚到上一版。
- 仓库需要配置的 secrets:
DEPLOY_HOST、DEPLOY_SSH_KEY、DEPLOY_KNOWN_HOSTS。这把部署 key 在服务器的authorized_keys里配置成restrict,command="/opt/haiguitang/bin/deploy",只能触发发版,不能登录、不能转发端口。 - 手动发版:
DEPLOY_HOST=<ssh 主机> deploy/push.sh。 - 手动回滚:
ln -sfn /opt/haiguitang/releases/<旧版本> /opt/haiguitang/current && systemctl restart haiguitang。 - systemd 配置见
deploy/haiguitang.service。
反向代理 + HTTPS:deploy/Caddyfile(推荐,自动申请证书)或 deploy/nginx.conf。主持人思考加重试可能超过一分钟,读超时已放到 150s。走反代时必须设置 TRUST_PROXY=1,否则按 IP 限流会把所有人算成同一个 IP(compose 和 service 文件里已经设好)。
不填 LLM_API_KEY 时会使用本地的模拟主持人(按参考问答做关键词匹配),页面左上角会标出“模拟主持人”,仅供调试前端。
- 走 OpenAI 兼容协议,默认
deepseek-flash+reasoning_effort=high,可通过LLM_BASE_URL / LLM_MODEL / LLM_REASONING_EFFORT切换。 - 提示词在
server/prompts.ts:通用规则与经典示例在前,具体故事资料在后,输出格式放在最后。每碗汤的系统提示词是固定文本,多用户共享前缀,能命中 DeepSeek 的上下文缓存。 - 模型输出结构化 JSON:
answer、note、milestones(玩家已确认的关键发现)、cue(一次性的氛围反馈)。 - 服务端兜底:
note和还原反馈里如果出现玩家从未说过的剧透词(host.spoilerTerms),整句丢弃;milestones和cue只接受白名单内的 id;cue强制冷却,两次之间至少间隔 4 问。
- 开发环境下,每问的推理摘要会打印在后端日志里(
[host] ...),方便调整提示词。
- 游戏状态全部在服务端(
server/sessions.ts),前端只持有 sessionId(存在 localStorage,刷新可恢复)。 - 同一会话:带独占锁,同时发来的第二个请求直接返回 409,不会出现两次 LLM 回调交错写入。
- 不同会话:互不阻塞。
- 上游 LLM:全局信号量限制在途请求数(
LLM_MAX_CONCURRENT),超出的排队,排队超时返回 503;SDK 自带 429/5xx 退避重试。 - 限流:按 IP 的令牌桶,分别限制提问频率和开局频率;部署在反向代理后面时设置
TRUST_PROXY=1。 - 当前是单进程内存存储 + 定期落盘(
SESSION_FILE,默认data/sessions.json)。要多实例部署(比如 Vercel 这类 Serverless),需要把SessionStore换成 Redis 实现(busy 锁对应SET NX PX),限流也要挪到 Redis,接口不用改。
- 在
stories/下新建一个 JSON(格式参考pei-wo-da-yi-ge.json),服务启动时会自动加载并校验。除了汤面、汤底、故事线、参考问答等内容,还需要一个host字段:milestones:推理过程中的关键发现,desc要写清“玩家说出什么才算达成”;hintTargets:每个提示对应哪个里程碑,已达成的提示会被跳过;spoilerTerms:主持人在备注里不能抢先说出的词;cues(可选):主持人可以触发的氛围反馈;passScore:通关分数线。
- 不做任何前端改动也能玩,会使用通用场景(
web/src/scenes/generic)。 - 想要专属布景:在
web/src/scenes/<story-id>/里实现一个SceneDef(场景组件、时钟、汤面变字、汤底朗读节奏、卡片插画等,见scenes/types.ts),然后在scenes/index.ts注册。
- 深夜的乡镇乒乓球训练馆。每问一个问题,时间就过去 18 分钟:从 23:50 走到 05:50 天亮,窗外天色随之变化。
- 每隔几秒响一组“乒……咚”,回应越来越慢、越来越轻;天亮后只剩“乒”。翻牌记分牌记录“我 : 爷爷”的次数。
- 第 8 问(半夜两点多),休息室的门会开一次。
- 里程碑触发的异变:
- 丧事:灯熄灭,长明灯、遗像、挽联、纸钱出现;
- 棺材:一号台上显出棺材,球拍挪到棺盖上;
- 求救:屏幕闪出“救”,汤面里的“球……球……”变成“救……救……”;
- 爸爸:门口一直站着一个人影;
- 轮回:棺材旁多了一个小孩。
- 所有声音都用 Web Audio 实时合成,包括空旷训练馆的混响,不需要音频素材。