Trepang Soup 是一个给朋友聚会准备的多人海龟汤桌面游戏。玩家用邀请码进入同一个房间,在聊天区交换猜想,再把经过整理的问题正式提交给主持人。主持人会回答“是”“否”“不相关”“部分正确”或“不能透露”,直到有人还原核心故事,或者所有人决定放弃并查看汤底。这里的 AI 没有扮演一个滔滔不绝的聊天对象,而是坐在服务端,承担出题、判题、提示、结案判断和赛后复盘。
项目的客户端使用 Vue、Pinia 与 Tauri,最终交付为 Windows 桌面应用;服务端由 FastAPI 提供建房、入场和管理接口,再用 WebSocket 推送房间里的连续事件。DeepSeek 只通过服务端访问,密钥、汤底和关键事实不会进入客户端。这个拆分看起来常见,但它决定了后面所有重要问题的答案:谁拥有最终状态、断线后怎样恢复,以及模型能向玩家说多少。
先把它当作一张真正的游戏桌
一局游戏里同时存在两类交流。聊天室允许玩家自由讨论,不会触发 AI,也不会被当作正式线索;正式问题则进入问题队列,由主持服务逐个处理,再把答案广播给所有人。这种区分很重要:朋友可以随意跑题、开玩笑,但只有主动提交的问题才改变公共推理记录。结算时,服务端根据正式问题、讨论、提示次数和最终结论生成评分与奖项,完整记录则保存在每个玩家自己的设备上。
亮点一:用事件序号把多人房间重新拼起来
实时同步最怕的不是延迟,而是每台客户端各自相信不同的状态。Trepang Soup 把 FastAPI 中的 Room 对象设为权威来源:客户端发送带 commandId 的命令,服务端完成校验和状态变更后生成带 eventId 的事件。commandId 让重复提交可以被识别,eventId 则让客户端知道事件是否重复、连续,或中间漏掉了一段。
if (event.eventId <= lastEventId.value) return;
if (lastEventId.value > 0 &&
event.eventId > lastEventId.value + 1) {
recoverFromEventGap();
return;
}
lastEventId.value = event.eventId;
updatePersistedEventId();首次连接时,客户端会在 session.hello 中带上自己保存的 lastEventId。服务端可以补发缺失事件,也可以直接返回 room.snapshot。快照不是简单的玩家名单,而是当前阶段下允许公开的完整视图:正在游戏时包含汤面、成员、公开问答、时间线和聊天;只有进入结算阶段才会包含汤底。断线重连、开局后加入和客户端重启因此走的是同一套恢复路径。
客户端传输层还处理了一个很容易漏掉的竞态:旧 WebSocket 的 close 事件可能晚于新连接到达。如果不判断“关闭的是否仍是当前 socket”,旧回调会把刚恢复的连接重新标记为断开。项目用 socket 身份和 intentionallyClosedSockets 区分主动关闭、旧连接迟到和真实掉线,再配合 1、2、4、8、15 秒退避重连,让网络波动不至于直接踢散一桌游戏。
亮点二:让模型做判断,把说话权留在服务端
海龟汤主持最危险的失败不是答错,而是不小心多说一句。模型如果自由生成解释,很容易在回答“是”的同时补充原因,或者在多人连续追问后自行总结出半段汤底。因此普通问答只把当前一个问题交给 DeepSeek,不附带历史问答;模型也不能直接生成玩家可见文本,只能返回一个 answerType。
SAFE_ANSWERS = {
AnswerType.YES: "是。",
AnswerType.NO: "否。",
AnswerType.IRRELEVANT: "不相关。",
AnswerType.PARTIAL: "部分正确,请拆成单个判断继续提问。",
AnswerType.CANNOT_REVEAL: "不能透露。",
}
# 不发送历史问答,避免模型拼接线索并主动复盘汤底。
del answered_questions
required_json = {
"answerType": "yes|no|irrelevant|partial|cannot_reveal"
}这层约束之外还有第二道边界。玩家输入和历史内容在系统规则里被明确标记为不可信数据,切换角色、索取提示词、要求翻译汤底或以调试名义列出事实都不能改变保密规则。题目生成则采用“生成者 + 独立审查者”两次结构化调用:候选题必须经过一致性、可推理性、知识门槛和多解风险检查,未通过就带着问题进入下一轮生成。JSON Schema 校验、格式修复、并发限制和退避重试负责把模型的不确定性关在服务适配层里。
结案也没有完全交给模型。模型判断核心冲突是否覆盖、遗漏了多少细节,服务端再根据固定阈值决定直接结算、要求二次确认还是继续推理,并按每项 6 分计算扣分。这样模型可以理解自然语言,却不能自行放宽胜利条件或随意改分。
亮点三:续局不是清空页面,而是一次原子换轮
结算后,所有仍属于房间的成员都要明确同意,系统才会准备下一碗汤。投票状态分为 voting 和 generating;玩家可以撤回,加入或离开会重新计算 eligiblePlayerIds,最后一票只能触发一次生成。AI 出题可能需要较长时间,所以服务端先释放房间锁,再在后台准备新题,避免整个房间在等待模型时停止响应。
async with self.lock:
if (
self.stage is not RoomStage.SETTLEMENT
or self.rematch_generation_id != generation_id
or target_round_number != self.round_number + 1
):
return
self.puzzle = puzzle
self.round_number = target_round_number
self.stage = RoomStage.PLAYING
self.questions.clear()
self.discussions.clear()
self.hint_count = 0
self.settlement = None
event = self._new_event(EventType.ROOM_RESTARTED, payload)generationId 和目标轮次用于拦住迟到结果:如果生成期间房间被关闭、另一轮已经开始,旧任务返回的题目会被丢弃。换轮成功时,题目、问答、讨论、提示和结算状态在同一把锁内一起替换;房间号、成员、房主、会话、连接邮箱和持续递增的 event_sequence 则保留下来。事件序号不能归零,否则在线客户端会把第二局的事件误判成第一局的重复消息。
从能运行到能交给朋友
客户端最终由 Tauri 打包成 Windows 安装程序,并通过 GitHub Releases 检查签名更新包、显示下载进度、安装后自动重启。安装包流量不经过游戏服务器,FastAPI 只处理房间与 AI 请求。项目还准备了双客户端 WebSocket 冒烟脚本,能够自动完成建房、加入、讨论、问答、提示、汤底保密、结算、全员续局投票和同房间第二轮;这比逐页点一遍更接近真实多人故障。
- 多人状态以服务端事件为准,客户端发现缺口就恢复快照,不在本地猜测缺失过程。
- 模型擅长的语义判断被保留,自由发挥、泄密和改规则的空间则由结构化输出收紧。
- 耗时 AI 任务放到房间锁之外执行,再用 generationId 和轮次检查安全提交结果。
- 桌面分发、在线更新和端到端冒烟测试让项目从演示界面走到了可以交给朋友使用的应用。
Trepang Soup 最有意思的地方,不是给传统海龟汤接上一个模型,而是把模型放进一套清楚的游戏协议里。房间状态、公开信息、胜负规则和失败恢复仍由普通程序掌握;AI 只处理那些确实需要理解语言的环节。结果是一张可以反复开局的数字游戏桌,而不是一个偶尔记得规则的聊天窗口。
