WebSocket API 文档
项目:对面是AI吗
协议:WebSocket
数据格式:JSON(文本帧)
基础URL:wss://game.xfcode.top
心跳:客户端发送{"type":"ping"},服务端回复{"type":"pong"},10s 间隔,65s 超时断开
更新日期:2026-10-06
通用约定
消息格式
所有消息均为 JSON 文本帧,以 type 字段区分消息类型:
{"type": "消息类型", ...其他字段}WebSocket 端点总览
服务端在 WebSocketHandler 中按 请求路径(onOpen)与 消息 type 前缀(onMessage)双重路由。各端点前缀与 Handler 对照见文末附录。
通用消息类型
所有游戏模式均支持以下通用消息:
| type | 方向 | 说明 |
|---|---|---|
ping | C→S | 心跳请求 |
pong | S→C | 心跳响应 |
get_stickers | C→S | 获取表情包差异更新 |
stickers_list | S→C | 表情列表(有变更时) |
stickers_unchanged | S→C | 表情无变化 |
error | S→C | 通用错误 {"type":"error","message":"..."} |
时间字段约定
- 所有
time/created_at/createdAt等时间字段均为 Unix 秒级时间戳(int),展示格式由客户端自行解析。 duration/timeout/seconds_remaining/ttl等为 相对时长(秒),非时间戳。
认证机制
所有 WebSocket 端点共用同一套身份认证体系,由 BaseGameHandler 统一实现。
连接阶段(onOpen)
WebSocket 连接建立时不进行身份验证,仅做以下检查:
- IP 提取 — 优先级:
CF-Connecting-IP>X-Forwarded-For>X-Real-IP>remote_addr - IP 去重(可选,默认开启)— 同一 IP 已有活跃连接时关闭旧连接
- IP 级封禁检查 — 调用
BanRepository::isBanned(),被封则推送{"type":"error","message":"您已被管理员封禁"}并关闭连接 - 全服公告推送 — 如有生效中的 Redis 广播,推送给新连接
- 在线人数推送 — 非聊天室端点推送
{"type":"online_count","count":N}(在线数按 IP 去重) - 连接过载拒绝 — 服务过载时推送
{"type":"error","message":"服务器繁忙,请稍后再试"}并关闭连接
真正的身份验证发生在客户端发送第一条业务消息时(如 join、lobby_join、board_gomoku_join、soup_lobby)。
业务消息认证(三级验证)
所有需要身份的消息均携带以下字段(三选一即可):
| 字段 | 说明 | 优先级 |
|---|---|---|
player_token | 已有的 JWT Token(老玩家) | 最高 |
password | 密码(新玩家注册/换设备恢复) | 中 |
仅 nickname | 昵称(兼容旧数据,需同设备 IP+FP 匹配) | 最低 |
Level 1:Token 验证(老玩家优先)
Client → Server: {type: "join", nickname: "小明", player_token: "eyJ..."}- Token 格式:
base64(json_payload).hmac_sha256_sig - Payload 结构:
{player_id, exp(365天), iat, jti} - HMAC 密钥:玩家在数据库中的
password_hash(bcrypt) - 验证内容:签名正确性 + 过期时间 + player_id 有效性
Token 可用跨模式复用 — 同一 player_id 可在 /ws、/ws/lobby、/ws/board/gomoku、/ws/board/go、/ws/soup 等各端点之间通用。
Level 2:密码验证(新玩家/换设备)
Client → Server: {type: "join", nickname: "小明", password: "mypassword"}- 按昵称查数据库,
password_verify($password, $record['password_hash']) - 密码正确 → 生成新 Token 并返回
- 密码错误 → 返回
{"type":"error","message":"密码不正确"},不中断连接,可重试 - 昵称不存在 → 视为新玩家注册,自动创建身份
Level 3:仅昵称(兼容旧迁移数据)
Client → Server: {type: "join", nickname: "小明"}- 按昵称查数据库
- 存在且同设备(IP + Fingerprint 匹配)→ 允许复用旧账号,生成新 Token
- 存在但设备不同 → 返回
{"type":"error","message":"该昵称已被占用,请换一个或输入密码"} - 不存在 → 视为新玩家,自动创建
新玩家注册
当昵称在数据库中不存在时,自动触发注册流程:
- 检查同 IP 账号数 ≤ 3(超出则拒绝,错误
不允许创建多个账号!) - 调用
PlayerStatsRepository::createPlayer(nickname, ip, fp, password)创建记录 - 密码使用传入的
password或随机生成bin2hex(random_bytes(8)) - BCrypt cost 10 加密存储
- 生成 JWT Token 返回给客户端
创建过于频繁时返回 账号创建过于频繁,请稍后再试。
在线唯一性
同一 player_id 通过 Redis SETNX 锁定(120s TTL),防止多地同时登录:
- 登录时
claimOnlineLock()尝试获取锁 - 连接关闭时自动释放
- 获取锁失败 → 关闭已有连接,推送
{"type":"system","text":"该账号已在其他地方登录,请先退出后再重试"}
验证失败处理汇总
| 端点 | 消息类型 | 失败响应 type | 失败示例 | 连接 |
|---|---|---|---|---|
/ws | join | error | {"type":"error","message":"密码不正确"} | 不断开 |
/ws/lobby | lobby_join | lobby_error | {"type":"lobby_error","text":"密码不正确"} | close |
/ws/board/gomoku | board_gomoku_join | board_gomoku_error | {"type":"board_gomoku_error","data":"密码不正确"} | 不断开 |
/ws/board/go | board_go_join | board_go_error | {"type":"board_go_error","data":"密码不正确"} | 不断开 |
/ws/soup | soup_lobby | soup_error | {"type":"soup_error","message":"密码不正确"} | 不断开 |
/ws/tempchat | temp_join | temp_error | {"type":"temp_error","text":"登录已失效,请重新登录"} | close |
| 全部 | onOpen | error | {"type":"error","message":"您已被管理员封禁"} | close |
| 全部 | online lock | system | {"type":"system","text":"该账号已在其他地方登录..."} | close |
Token 机制(内部)
生成: base64(json_encode({player_id, exp(365天), iat, jti(随机8字节)})) . "." . hmac_sha256(payload, password_hash)
验证: 解析 payload + sig → 查 DB 获取 password_hash → 重新计算 HMAC 对比
密钥: 每个玩家独立的 bcrypt password_hash
有效期: 365 天1. "对面是AI吗" 主游戏
WebSocket 路径:/ws
Handler:GameWebSocketHandler
1.1 通用消息
error
方向:S→C
{"type":"error","message":"ERROR_MESSAGE"}ping / pong
C→S:
{"type":"ping"}S→C:
{"type":"pong"}说明:心跳机制,每 10 秒一次。65 秒无心跳则断开连接。
1.2 匹配与对局流程
join(加入匹配)
方向:C→S
{
"type": "join",
"nickname": "小明",
"duration": 600,
"fingerprint": "可选的设备指纹",
"password": "密码(新玩家创建身份或恢复身份时)",
"player_token": "已有的 token(已登录时)",
"reconnect_session_id": "重连恢复时的 session_id"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
nickname | string | 是 | 1-16 字符 |
duration | int | 是 | 对局时长:300(5分钟)或 600(10分钟) |
fingerprint | string | 否 | 设备指纹 |
password | string | 条件 | 新玩家创建身份或恢复时 |
player_token | string | 条件 | 已有 token 时,与 password 二选一 |
reconnect_session_id | string | 否 | 断线重连恢复 |
说明:加入匹配队列。身份验证成功后进入等待。
matched(匹配成功)
方向:S→C
{
"type": "matched",
"opponent_name": "小红",
"duration": 600,
"session_id": "sess_abc123",
"player_id": "player_xxx",
"token": "eyJ..."
}| 字段 | 说明 |
|---|---|
opponent_name | 对手昵称(可能为 AI 别名) |
duration | 对局时长 |
session_id | 对局会话 ID |
player_id | 你的玩家 ID |
token | JWT Token(新玩家首次匹配时下发) |
说明:匹配成功后自动推送,开始聊天阶段。
message(发送聊天消息)
方向:C→S
{
"type": "message",
"text": "你好,请问你是 AI 吗?"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
text | string | 是 | 消息内容,最多 300 字符 |
触发条件:对局状态为 chatting(聊天阶段)。
对手收到:
{"type":"message","text":"你好,请问你是 AI 吗?","sender":"对方"}旁观者收到:
{"type":"spectate_message","text":"...","sender":"玩家1","side":"left"}sticker(发送表情)
方向:C→S
{"type":"sticker","id":"表情ID"}对手收到:
{"type":"sticker","id":"xxx","name":"大笑","url":"/stickers/xxx.png","sender":"对方"}judge(判定)
方向:C→S
{
"type": "judge",
"guess": "human",
"tag": "有逻辑"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
guess | string | 是 | "human"(认为是真人)或 "ai"(认为是 AI) |
tag | string | 否 | 标签,用于风格画像 |
触发条件:聊天阶段结束进入判定阶段。
对手判定进度通知(对手收到):
{
"type": "judge_notify",
"message": "对方已做出判断,你需要在 60 秒内完成判定,否则判负",
"seconds_remaining": 60
}判定结果(双方都判定完成后,双方收到):
{
"type": "judged",
"truth": "human",
"opponent_guess": "ai",
"opponent_tag": "机器人",
"session_id": "sess_abc123",
"opponent_name": "小红",
"player_id": "player_xxx"
}| 字段 | 说明 |
|---|---|
truth | 你的真实身份:"human" 或 "ai" |
opponent_guess | 对手对你的判断 |
opponent_tag | 对手给你的标签 |
leave(主动离开)
方向:C→S
{"type":"leave"}对手收到:
{"type":"system","text":"对方已离开"}然后:
{
"type":"timeout",
"reason":"opponent_left",
"opponent_truth":"ai",
"session_id":"sess_abc123",
"opponent_name":"小红"
}leave_result(确认离开结果页)
方向:C→S
{"type":"leave_result","session_id":"sess_abc123"}说明:查看对战结果后确认离开,不主动断连,仅标记离开意向用于跨进程同步清理。
save_history(保存聊天记录)
方向:C→S
{"type":"save_history","session_id":"sess_abc123"}响应:
{"type":"save_history_status","success":true,"message":"聊天记录已保存","id":123}leave_message(留言)
方向:C→S
{"type":"leave_message","text":"打得很精彩!"}响应:
{"type":"leave_message_status","success":true,"message":"留言已保存"}report(举报)
方向:C→S
{"type":"report","reason":"对方使用了不文明用语"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
reason | string | 是 | 举报原因,最多 100 字符 |
响应:
{"type":"report_result","success":true,"message":"举报已提交,管理员将尽快处理"}update_nickname(更新昵称)
方向:C→S
{
"type": "update_nickname",
"nickname": "新昵称",
"fp": "设备指纹"
}响应:
{"type":"update_nickname_result","success":true}
// 或
{"type":"update_nickname_result","error":"昵称已被占用"}1.3 服务器主动推送
timeout(超时)
方向:S→C
{
"type": "timeout",
"reason": "chat_expired",
"session_id": "sess_abc123",
"opponent_name": "小红"
}| reason 值 | 说明 |
|---|---|
chat_expired | 聊天时间到,进入判定阶段 |
you_timeout | 你判定超时,判负 |
opponent_timeout | 对手判定超时,你判胜 |
both_timeout | 双方判定超时,平局 |
opponent_left | 对手离开了对局 |
no_mutual_chat | 60 秒内双方无互发消息,对局终止 |
system(系统消息,对局内)
方向:S→C
{"type":"system","text":"已进入判定阶段,请判断对方是真人还是 AI"}1.4 管理员消息
admin_ban(管理员封禁)
方向:C→S
{"type":"admin_ban","token":"管理员token","reason":"违规行为"}被禁者收到:
{"type":"banned","text":"你已被管理员封禁。原因:违规行为"}admin_verify(验证管理员)
方向:C→S
{"type":"admin_verify","token":"管理员token"}响应:
{"type":"admin_config","ws_url":"/admin/ws","super_admin":true}
ws_url由Config/App.php的Admin.Path(默认admin)动态拼装为/{Admin.Path}/ws。
2. 大厅聊天室
WebSocket 路径:/ws/lobby
Handler:LobbyChatWebSocketHandler
前缀:lobby_
大厅聊天室为常驻公共聊天室,支持文本 / 表情 / 图片 / 卡片分享、@ 提醒、拍一拍、点歌、MD 投票与管理员治理。消息广播有 64KB 上限保护(超大消息自动降级)。
2.1 连接建立
连接建立后不立即下发历史/在线列表,需先发送 lobby_join 完成身份验证。
lobby_join 通过后依次下发:
{"type":"lobby_joined","nickname":"小明","player_id":"player_xxx","token":"eyJ...","sender_titles":[],"sender_special_titles":[]}
{"type":"lobby_history","messages":[...]}
{"type":"lobby_online_count","players":[{"fd":1,"nickname":"小明","player_id":"player_xxx","is_bot":0,"muted":0,"isolated":0}]}并向其他在线用户广播进入通知(不发给本人):
{"type":"lobby_system","text":"小明 进入了聊天室"}跨 Handler 复用:棋类等待页可通过
board_{game}_open_lobby_chat把棋局连接挂载进聊天室,同一 fd 参与广播与在线列表;此时聊天室侧的lobby_*消息由路由层按前缀转交。
2.2 用户消息
lobby_join(加入聊天室)
方向:C→S
{
"type": "lobby_join",
"nickname": "小明",
"password": "密码",
"player_token": "已有 token"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
nickname | string | 是 | 1-12 字符 |
password | string | 条件 | 新玩家/恢复身份时 |
player_token | string | 条件 | 已有 token 时 |
错误(lobby_error):昵称 1~12 字符、密码不正确、您已被管理员封禁...(后三者会断开连接)。
响应:见 2.1 连接建立。
lobby_set_fp(上报设备指纹)
方向:C→S
{"type":"lobby_set_fp","fp":"设备指纹"}说明:补充连接级指纹,用于封禁复查;无响应。
lobby_chat(发送聊天消息)
方向:C→S
{
"type": "lobby_chat",
"content": "大家好!",
"reply_to_id": 123,
"reply_to_name": "小红",
"reply_to_text": "原始消息内容"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
content | string | 是 | 消息内容 |
reply_to_id | int | 否 | 回复的消息 ID |
reply_to_name | string | 否 | 回复对象的昵称 |
reply_to_text | string | 否 | 回复消息的原文摘要(最多 100 字符) |
触发条件:已加入聊天室 + 未被禁言 + 未被孤立 + 通过频率限制。
广播(普通文本):
{
"type": "lobby_chat",
"id": 456,
"sender_name": "小明",
"sender_id": "player_xxx",
"content": "大家好!",
"msg_type": "",
"is_bot": false,
"sender_titles": [],
"sender_special_titles": [],
"reply_to": {"id":123,"name":"小红","text":"原始消息内容"},
"mentions": ["小红"],
"time": 1730000000,
"created_at": 1730000000
}广播(含特殊 MD 语法):
{
"type": "lobby_chat",
"id": 457,
"sender_name": "小明",
"sender_id": "player_xxx",
"content": {
"v": 1,
"blocks": [
{ "t": "text", "text": "投票:" },
{ "t": "vote", "question": "今天午饭吃什么", "options": ["米饭", "面条"], "max": 1, "mode": "bar", "sync": true }
]
},
"msg_type": "markdown",
"is_bot": false,
"sender_titles": [],
"sender_special_titles": [],
"reply_to": null,
"mentions": [],
"time": 1730000060,
"created_at": 1730000060
}| 广播字段 | 类型 | 说明 |
|---|---|---|
msg_type | string | markdown(含特殊 MD 语法)/ 空字符串(普通文本)/ card.share.record / card.invite.board.gomoku / card.invite.board.go / card.share.fate |
content | object|string | markdown 时为 blocks 对象 {"v":1,"blocks":[...]};普通文本时为纯文本原文;卡片消息时为卡片 JSON 对象 |
is_bot | bool | 是否来自 BOT 网关 |
sender_titles / sender_special_titles | string[] | 发送者佩戴标签 / 特殊标签 |
reply_to | object|null | 引用消息 {id,name,text} |
mentions | string[] | 本条消息实际 @ 到的在线昵称 |
time / created_at | int | Unix 秒级时间戳 |
@提醒(被 @ 的用户收到):
{
"type": "lobby_mentioned",
"message_id": 456,
"sender_name": "小明",
"content": "大家好!"
}仅当消息含特殊 MD 语法(组件语法
[!...](类型:...)或%变量%引用)时,后端才解析为 blocks 并将msg_type设为markdown;普通文本消息保持纯文本。@全体成员仅对管理员生效。
被禁言 / 频率限制回复(lobby_system):
{"type":"lobby_system","text":"你已被禁言,剩余 10 分钟"}
{"type":"lobby_system","text":"发言太频繁,请等待 3 秒"}lobby_sticker(发送表情)
方向:C→S
{"type":"lobby_sticker","id":"表情ID"}广播(type 为 sticker,非 lobby_sticker):
{
"type": "sticker",
"id": "xxx",
"sticker_id": "xxx",
"sticker_name": "大笑",
"sticker_url": "/stickers/xxx.png",
"sender_name": "小明",
"sender_id": "player_xxx",
"is_bot": false,
"time": 1730000000,
"created_at": 1730000000
}lobby_image(发送图片)
方向:C→S
{"type":"lobby_image","key":"32位一次性key","client_id":"32位客户端关联id"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
key | string | 是 | 由 /api/image/upload 发放的一次性 key(32 位 hex,TTL 120 秒) |
client_id | string | 否 | 客户端生成的关联 ID,随广播原样回带(不入库),供前端把本地占位气泡对账成真实消息 |
广播(type 为 image):
{
"type": "image",
"content": "https://.../xxx.png",
"sender_name": "小明",
"sender_id": "player_xxx",
"sender_titles": [],
"sender_special_titles": [],
"time": 1730000000,
"created_at": 1730000000,
"client_id": "客户端关联id"
}错误(lobby_system):图片已过期,请重新上传、发送太频繁,请等待 N 秒、你已被禁言,剩余 N 分钟、你还未加入聊天室。
lobby_nudge(拍一拍)
方向:C→S
{"type":"lobby_nudge","target_fd":123,"target_nickname":"小红"}目标收到:
{"type":"lobby_nudged","sender_name":"小明"}广播:
{"type":"lobby_system","text":"小明 拍了拍 小红"}限制:每个发送者 5 秒一次,超限返回 {"type":"lobby_error","text":"拍得太频繁了,请稍后再试"}。
lobby_revoke(撤回消息)
方向:C→S
{"type":"lobby_revoke","message_id":456}限制:只能撤回自己 3 分钟内的消息。
广播:
{"type":"lobby_revoke","message_id":456,"sender_name":"小明"}错误(lobby_error):无效的消息 ID、请先进入聊天室、撤回失败:消息不存在、不是你的消息或已超过3分钟。
lobby_report(举报消息)
方向:C→S
{"type":"lobby_report","message_id":456,"reason":"不文明用语"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
message_id | int | 是 | 被举报消息 ID |
reason | string | 否 | 举报原因,最多 255 字符(缺省为「违规消息」) |
响应:
{"type":"lobby_report_ok","message":"举报已提交,管理员将尽快处理"}错误(lobby_error):无效的消息 ID、请先设置昵称、消息不存在或已被删除、该消息已被举报,请等待管理处理。
lobby_card_share(分享战绩卡片)
方向:C→S
{"type":"lobby_card_share"}说明:服务端从数据库读取真实战绩生成卡片(防伪造),受发言频率限制。
广播:一条 lobby_chat 消息,msg_type = card.share.record,content 为卡片对象:
{
"type": "record",
"version": 1,
"title": "小明的战绩",
"player": "小明",
"fields": {"wins":10,"losses":5,"games":15,"rate":67},
"footer": "XQFGameHub"
}本人另外收到 {"type":"lobby_system","text":"战绩卡片已分享到聊天室"}。
错误(lobby_system):操作太频繁,请等待 N 秒。
lobby_btn_click(按钮点击次数限制)
方向:C→S
{
"type": "lobby_btn_click",
"key": "按钮key",
"userName": "小明",
"rule": {"mode":"global","globalLimit":10,"perUserLimit":3,"extra":{}}
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
key | string | 是 | 按钮唯一键 |
userName | string | 否 | 当前用户名(per-user / mixed 模式用) |
rule.mode | string | 否 | global / per-user / mixed(默认 global) |
rule.globalLimit | int | 否 | 全局次数上限 |
rule.perUserLimit | int | 否 | 每用户次数上限 |
rule.extra | object | 否 | mixed 模式下特定用户覆盖上限 {用户名: 上限} |
响应:
{"type":"lobby_btn_click_result","key":"按钮key","allowed":true,"remaining":9}| 字段 | 说明 |
|---|---|
allowed | 是否允许 |
remaining | 剩余次数(-1 表示无限制) |
lobby_poll_vote(MD 投票)
方向:C→S
{"type":"lobby_poll_vote","message_id":456,"vote_id":"v1","options":[0,2]}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
message_id | int | 是 | 投票所在消息 ID |
vote_id | string | 是 | 投票组件 ID |
options | int[] | 是 | 该用户当前全部已选选项下标(0-19,最多 10 项) |
广播(服务端按用户差分更新票数后广播,仅实时同步不写历史):
{"type":"lobby_poll_update","vote_key":"456:v1","counts":{"0":3,"2":1}}错误(lobby_error):请先进入聊天室。
2.3 点歌系统
lobby_song_search(搜索歌曲)
方向:C→S
{"type":"lobby_song_search","keyword":"周杰伦"}说明:若 keyword 为网易云分享链接 / 短链 / 纯歌曲 ID,则直接按该歌曲点歌,响应带 direct_requested。
响应:
{"type":"lobby_song_search_result","keyword":"周杰伦","songs":[{...}]}
// 或(直接点歌)
{"type":"lobby_song_search_result","direct_requested":true,"keyword":"...","song":{...}}错误(lobby_song_search_result 内 error):请输入搜索关键词、请先设置昵称后再通过分享链接点歌、身份验证失败,请重新进入。
lobby_song_request(点歌)
方向:C→S
{"type":"lobby_song_request","song_id":"xxx","nickname":"小明"}响应:
{"type":"lobby_song_requested","song":{...}}错误(lobby_error):请先设置昵称、身份验证失败,请重新进入、无效的歌曲ID。
lobby_song_vote / lobby_song_remove_vote(投票/取消投票)
{"type":"lobby_song_vote","song_id":"xxx"}{"type":"lobby_song_remove_vote","song_id":"xxx"}广播:
{"type":"lobby_vote_update","song_id":"xxx","votes":5}
{"type":"lobby_remove_vote_update","song_id":"xxx","remove_votes":3}错误(lobby_error):无效的歌曲ID、请先进入聊天室。
lobby_song_list / lobby_song_current(歌单查询)
方向:C→S
{"type":"lobby_song_list"}
{"type":"lobby_song_current"}响应(歌单):
{"type":"lobby_song_list","playlist":[...],"pool":[...],"playing":{...}}响应(当前播放):
{"type":"lobby_song_current","song":{...}}
// 或等待中
{"type":"lobby_song_current","waiting":true}广播(列表更新):
{"type":"list_update","playlist":[...],"pool":[...],"playing":{...}}lobby_song_rcmd(随机推荐)
方向:C→S
{"type":"lobby_song_rcmd"}响应:
{"type":"lobby_song_rcmd_result","song":{...}}
// 或
{"type":"lobby_song_rcmd_result","error":"..."}lobby_song_import(导入歌单)
方向:C→S
{"type":"lobby_song_import","keyword":"歌单链接或ID"}进度:
{"type":"lobby_song_import_progress","done":5,"total":20,"name":"歌单名"}结果:
{"type":"lobby_song_import_result","result":{...},"text":"..."}
// 或
{"type":"lobby_song_import_result","error":"..."}lobby_song_admin_remove(管理员移除歌曲)
方向:C→S
{"type":"lobby_song_admin_remove","song_id":"xxx"}说明:仅管理员可用,移除后广播 lobby_system 提示。
lobby_song_finished(客户端上报播放结束)
方向:C→S
{"type":"lobby_song_finished"}说明:客户端播放结束时上报,服务端据此推进歌单/广播。
2.4 管理员消息
以下消息仅对管理员 fd 生效(通过
lobby_admin_verify本地注册、Tracker fd 或同 IP 判定),非管理员调用将被静默忽略。
lobby_delete(管理员删消息)
{"type":"lobby_delete","message_id":456}广播:
{"type":"lobby_message_deleted","message_id":456}lobby_mute(管理员禁言)
{"type":"lobby_mute","target_fd":123,"minutes":10}目标收到:
{"type":"lobby_system","text":"你已被管理员禁言 10 分钟"}广播:
{"type":"lobby_system","text":"小明 已被管理员禁言 10 分钟"}lobby_unmute(管理员解除禁言)
{"type":"lobby_unmute","target_fd":123}目标收到:{"type":"lobby_system","text":"你已被管理员解除禁言"}
广播:{"type":"lobby_system","text":"小红 的禁言已被解除"}
lobby_isolate(管理员孤立)
{"type":"lobby_isolate","target_fd":123,"minutes":10}说明:孤立期间该玩家消息不广播(仅本人可见),静默执行,不提醒其他玩家。仅操作者本人收到 lobby_system 提示。
lobby_unisolate(管理员解除孤立)
{"type":"lobby_unisolate","target_fd":123}说明:静默解除,仅操作者本人收到 lobby_system 提示。
lobby_ban(管理员封禁)
{"type":"lobby_ban","target_fd":123,"reason":"违规"}执行封禁(IP + 指纹 + player_id)并断开目标 WebSocket 连接。
lobby_kick(管理员踢出)
{"type":"lobby_kick","target_fd":123}被踢者收到:
{"type":"lobby_kicked","message":"您已被管理员踢出聊天室"}说明:断开连接但不封禁,被踢者可重新进入。
lobby_admin_verify(管理员验证)
{"type":"lobby_admin_verify","token":"管理员token"}响应:
{
"type": "lobby_admin_verified",
"is_admin": true,
"username": "admin",
"role": "super_admin",
"super_admin": true
}验证失败时返回 {"type":"lobby_admin_verified","is_admin":false}。
3. 五子棋
WebSocket 路径:/ws/board/gomoku
Handler:GomokuHandler(继承 AbstractBoardGameHandler)
前缀:board_gomoku_
五子棋与围棋共用棋类对局外壳(AbstractBoardGameHandler):身份验证、房间、观战、对局内聊天、再弈、断线判负流程完全一致,仅棋盘规则不同。消息类型由前缀动态拼装:board_gomoku_ + 动作后缀。
3.1 连接建立
服务端推送(连接成功后):
{"type":"board_gomoku_connected"}3.2 棋类公共动作
以下动作五子棋 / 围棋通用,示例以 board_gomoku_ 前缀;围棋只需将前缀替换为 board_go_(见 4 节)。
join(声明身份)
方向:C→S
{
"type": "board_gomoku_join",
"fp": "设备指纹",
"nickname": "小明",
"password": "密码",
"player_token": "已有 token"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
fp | string | 否 | 设备指纹 |
nickname | string | 是 | 昵称 |
password | string | 条件 | 新玩家/恢复身份时 |
player_token | string | 条件 | 已有 token 时,与 password 二选一 |
响应:
{"type":"board_gomoku_joined","data":{"token":"eyJ...","player_id":"player_xxx"}}create_room(创建房间)
方向:C→S
{
"type": "board_gomoku_create_room",
"firstMove": "host",
"boardSize": 15
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
firstMove | string | 否 | "host"=房主执黑先手(默认);其它值=让对手先手 |
boardSize | int | 否 | 棋盘大小,五子棋 5-30(默认 15);围棋仅支持 9/13/19(默认 19) |
响应:
{"type":"board_gomoku_room_created","data":{"roomId":"ABCDE","color":1,"player_id":"player_xxx"}}| 字段 | 说明 |
|---|---|
roomId | 5 位房间码,用于分享和加入 |
color | 你的棋子颜色:1=黑棋(先手),2=白棋 |
player_id | 你的玩家 ID |
错误:身份验证失败(未先完成 join)。
join_room(加入房间)
方向:C→S
{"type":"board_gomoku_join_room","roomId":"ABCDE"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
roomId | string | 是 | 5 位房间码 |
场景一:作为对手加入(空位),发送给双方
房主收到:
{
"type": "board_gomoku_game_start",
"data": {
"roomId": "ABCDE",
"settings": {"boardSize":15,"firstMove":"host"},
"currentTurn": 1,
"myColor": 1,
"player_id": "player_xxx",
"opponent": {"nickname":"小红","player_id":"player_yyy"}
}
}加入者收到同结构消息,myColor 为 2,opponent 为房主身份。
场景二:作为旁观者加入(已有对手)
{
"type": "board_gomoku_spectate_start",
"data": {
"roomId": "ABCDE",
"settings": {"boardSize":15,"firstMove":"host"},
"board": [[0,0,...],[0,0,...]],
"currentTurn": 1
}
}若房间对局已结束,观战者会额外收到一次 board_gomoku_game_over。
错误(board_gomoku_error):
| data 文案 | 触发场景 |
|---|---|
身份验证失败 | 未先完成 join |
无效的房间号 | 房间码为空 |
房间不存在 | 房间不存在或已解散 |
你已是该房间房主 | 房主加入自己房间 |
房主已离开,房间失效 | 房主连接已断开 |
cancel_wait(取消等待)
方向:C→S
{"type":"board_gomoku_cancel_wait"}说明:房主取消等待,销毁房间并退出等待页聊天大厅。 错误:没有进行中的房间。
surrender / timeout(认输 / 超时)
方向:C→S
{"type":"board_gomoku_surrender"}{"type":"board_gomoku_timeout"}广播终局:
{"type":"board_gomoku_game_over","data":{"winner":2,"reason":"认输","winPath":[]}}request_rematch(请求再弈)
方向:C→S
{"type":"board_gomoku_request_rematch"}说明:双方都发送后自动重新开局,黑白互换;双方收到 board_gomoku_game_start,观战者收到 board_gomoku_spectate_start。
chat_message(局内聊天)
方向:C→S
{"type":"board_gomoku_chat_message","msg":"好棋!"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
msg | string | 是 | 消息内容,最多 300 字符 |
广播(对手+观战者,不含发送者):
{"type":"board_gomoku_chat_message","data":{"msg":"好棋!","from":1,"time":1730000000}}| 字段 | 说明 |
|---|---|
from | 1=黑棋方,2=白棋方 |
time | Unix 秒级时间戳 |
share_invite(分享邀请到聊天室)
方向:C→S
{"type":"board_gomoku_share_invite"}说明:向大厅聊天室广播一条 board_gomoku_invite 邀请卡片(受禁言与发言频率限制)。
响应:
{"type":"board_gomoku_invite_result","success":true,"message":"对局邀请已发送到聊天室"}message 文案(success:false) | 触发场景 |
|---|---|
请先创建房间 | 无房间 |
房间不存在或已结束 | 房间已销毁 |
身份验证失败 | 未完成 join |
聊天室暂不可用 | lobbyHandler 未注入 |
你已被禁言,剩余 N 分钟 / 操作太频繁,请等待 N 秒 | 发言限制 |
open_lobby_chat / close_lobby_chat(等待页聊天大厅)
方向:C→S
{"type":"board_gomoku_open_lobby_chat"}{"type":"board_gomoku_close_lobby_chat"}说明:等待对手期间复用同一条连接挂载聊天室(发送的 lobby_* 消息由路由层按前缀转给聊天室)。
响应:
{"type":"board_gomoku_lobby_chat_result","success":true,"message":""}失败时 message 为 请先完成身份验证 / 聊天大厅暂不可用 / 进入聊天大厅失败。
error(错误)
方向:S→C
{"type":"board_gomoku_error","data":"错误描述"}opponent_disconnected(对手断线)
方向:S→C
对局者断线时,对手收到:
{"type":"board_gomoku_opponent_disconnected","data":{"msg":"对手已断开连接,对局结束。"}}观战者收到:
{"type":"board_gomoku_opponent_disconnected","data":{"msg":"对局者已离开,对局结束。"}}3.3 五子棋专属动作
place_piece(落子)
方向:C→S
{"type":"board_gomoku_place_piece","r":7,"c":7}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
r | int | 是 | 行坐标(0-based) |
c | int | 是 | 列坐标(0-based) |
广播(双方+观战者):
{
"type": "board_gomoku_piece_placed",
"data": {"r":7,"c":7,"color":1,"nextTurn":2}
}game_over(游戏结束)
方向:S→C
{
"type": "board_gomoku_game_over",
"data": {
"winner": 1,
"reason": "win",
"winPath": [[3,3],[4,4],[5,5],[6,6],[7,7]]
}
}| 字段 | 说明 |
|---|---|
winner | 1=黑棋胜,2=白棋胜,0=平局 |
reason | "win"(五连)、"draw"(棋盘下满平局)、"认输"、"超时" |
winPath | 获胜的 5 个棋子坐标(非 win 时为空数组) |
3.4 断线处理
- 对局中断线 → 断线方判负,对手与观战者收到
board_gomoku_opponent_disconnected,房间删除,双方战绩异步落库 - 观战者断线 → 静默移除,不影响对局
- 对局已结束断线 → 仅清理连接绑定
4. 围棋
WebSocket 路径:/ws/board/go
Handler:GoHandler(继承 AbstractBoardGameHandler)
前缀:board_go_
对局外壳与五子棋完全一致(见 3.2 节棋类公共动作),将消息前缀替换为 board_go_ 即可:board_go_join / board_go_create_room / board_go_join_room / board_go_cancel_wait / board_go_surrender / board_go_timeout / board_go_request_rematch / board_go_chat_message / board_go_share_invite / board_go_open_lobby_chat / board_go_close_lobby_chat / board_go_error / board_go_opponent_disconnected(邀请卡片 card 为 board_go_invite,msg_type 为 card.invite.board.go)。
差异点:
- 棋盘规格仅支持 9 / 13 / 19(
GoService::normalizeSize,默认 19) - 采用中国规则数子法,贴目 7.5(
komi),故不存在和棋 - 落子一律以服务端为准(客户端不做乐观渲染,等待
board_go_stone_placed回传完整盘面) - 终局后支持「续弈」
4.1 连接建立
{"type":"board_go_connected"}4.2 开局附加字段
board_go_game_start 在公共字段基础上附加 komi:
{
"type": "board_go_game_start",
"data": {
"roomId": "ABCDE",
"settings": {"boardSize":19,"firstMove":"host"},
"currentTurn": 1,
"komi": 7.5,
"myColor": 1,
"player_id": "player_xxx",
"opponent": {"nickname":"小红","player_id":"player_yyy"}
}
}4.3 围棋专属动作
place_stone(落子)
方向:C→S
{"type":"board_go_place_stone","r":3,"c":3}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
r | int | 是 | 行坐标(0-based) |
c | int | 是 | 列坐标(0-based) |
广播(双方+观战者):
{
"type": "board_go_stone_placed",
"data": {
"r": 3,
"c": 3,
"color": 1,
"captured": 0,
"nextTurn": 2,
"board": [[0,0,...],[0,0,...]],
"captures": {"1":0,"2":0},
"koHash": "",
"passes": 0,
"moveNumber": 1
}
}| 字段 | 说明 |
|---|---|
captured | 本手提子数 |
board | 完整盘面(0=空,1=黑,2=白) |
captures | 双方累计提子数 |
koHash | 打劫盘面哈希 |
moveNumber | 手数 |
非法手仅回给本人(前端据此提示,盘面保持服务端权威):
{"type":"board_go_move_rejected","data":{"r":3,"c":3,"reason":"禁止自杀"}}| reason 文案 | 触发场景 |
|---|---|
坐标超出棋盘 | 坐标越界 |
该交叉点已有棋子 | 目标点已有子 |
禁止自杀 | 落子后自身无气 |
打劫:不能立即回提 | 违反打劫规则 |
pass(虚手)
方向:C→S
{"type":"board_go_pass"}广播:
{
"type": "board_go_passed",
"data": {"color":1,"nextTurn":2,"passes":1,"board":[...],"captures":{...},"moveNumber":2}
}说明:连续两次虚手即终局数子。
request_resume(续弈)
方向:C→S
{"type":"board_go_request_resume"}说明:终局(canResume=true)后,双方各确认一次可回到终局前盘面继续收束死子。
对方收到:
{"type":"board_go_resume_requested","data":{"msg":"对方请求续弈,点击「续弈」继续收束棋局。"}}双方都确认后广播:
{
"type": "board_go_resumed",
"data": {
"roomId": "ABCDE",
"settings": {...},
"board": [...],
"currentTurn": 1,
"captures": {...},
"passes": 0,
"lastMove": null,
"moveNumber": 42,
"komi": 7.5
}
}4.4 游戏结束
方向:S→C
{
"type": "board_go_game_over",
"data": {
"winner": 1,
"reason": "数子",
"score": {
"black": 55,
"white": 48.5,
"black_stones": 40,
"white_stones": 35,
"black_territory": 15,
"white_territory": 13.5,
"neutral": 0,
"komi": 7.5,
"winner": 1,
"owner": "black"
},
"canResume": true
}
}| 字段 | 说明 |
|---|---|
winner | 1=黑胜,2=白胜 |
reason | "数子"(两次虚手)、"认输"、"超时" |
score | 数子结果;认输/超时时为 null |
canResume | 是否可续弈(仅数子终局为 true) |
5. 海龟汤
WebSocket 路径:/ws/soup
Handler:SoupWebSocketHandler
前缀:soup_
开关:Soup.Enabled(默认开启)
海龟汤(真人房)为多人推理玩法:出题人(房主)持有汤底,猜题人提问,出题人判定。房间状态:lobby(大厅)→ picking(选题)→ playing(对局)→ revealed(结算)。
防作弊:汤底(truth/key_points/hints)仅下发给房主(出题人)与结算揭示时;猜题人端永远拿不到。
5.1 连接建立
服务端推送(连接成功后):
{"type":"soup_connected"}
{"type":"soup_room_list","rooms":[...]}online_count 也会按通用逻辑推送。
5.2 身份与大厅
soup_lobby(进入大厅并拉取房间列表)
方向:C→S
{
"type": "soup_lobby",
"fp": "设备指纹",
"nickname": "小明",
"password": "密码",
"player_token": "已有 token"
}响应(身份验证通过后):
{"type":"soup_joined","data":{"token":"eyJ...","player_id":"player_xxx","nickname":"小明"}}
{"type":"soup_room_list","rooms":[...]}5.3 房间消息
soup_create(创建房间)
方向:C→S
{
"type": "soup_create",
"room_name": "我的汤面房",
"source": "public",
"puzzle_id": 0
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
room_name | string | 否 | 房间名(最多 24 字符),缺省为「昵称 的汤面房」 |
source | string | 否 | 题目来源(如 public/random),配合 puzzle_id 可建房即选题 |
puzzle_id | int | 否 | 指定题目 ID |
响应:
{"type":"soup_room_created","room_id":"ABCDEF"}错误(soup_error):你已在一个房间中。
soup_join_room(加入房间)
方向:C→S
{"type":"soup_join_room","room_id":"ABCDEF"}响应:
{"type":"soup_system","text":"已加入房间「我的汤面房」,等待出题人开始"}同时广播 soup_room 房间快照。
错误(soup_error):
| message 文案 | 触发场景 |
|---|---|
你已在一个房间中 | 重复加入 |
房间不存在或已解散 | 房间码无效 |
对局已开始,无法加入 | 房间处于 playing/revealed |
房间已满 | 人数达到 Soup.MaxPlayers(默认 6) |
soup_pick(出题人选题)
方向:C→S
{"type":"soup_pick","source":"random","puzzle_id":0}错误:只有出题人可以选题、对局进行中,无法更换题目。
soup_ready(出题人开始)
方向:C→S
{"type":"soup_ready"}错误:只有出题人可以开始、请先选择一道汤面、至少需要 N 人才能开始(Soup.MinPlayers,默认 2)。
开始后按视角广播 soup_room 与 soup_round。
soup_leave(离开房间)
方向:C→S
{"type":"soup_leave"}响应:
{"type":"soup_left"}说明:房主离开 → 解散房间并通知所有成员(房主已解散房间);猜题人离开 → 广播 soup_system「X 离开了房间」。
5.4 对局消息
soup_ask(猜题人提问)
方向:C→S
{"type":"soup_ask","text":"他是意外死亡的吗?"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
text | string | 是 | 问题内容,最多 200 字符 |
广播:
{
"type": "soup_question",
"question_id": 1,
"text": "他是意外死亡的吗?",
"nickname": "小明",
"kind": "ask",
"index": 1,
"question": "他是意外死亡的吗?",
"verdict": null
}错误:当前不在对局中、出题人不能提问、本题提问已达上限,请提交汤底或放弃(Soup.MaxQuestions,默认 30)、提问太快了,请稍候(Soup.AskRateLimit,默认 3 秒)、提问内容不能为空。
soup_ai_suggest(出题人请求 AI 判定建议)
方向:C→S
{"type":"soup_ai_suggest","question_id":1}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
question_id | int | 否 | 指定问题 ID,缺省取最后一条未判定问题 |
响应:
{"type":"soup_ai_suggested","question_id":1,"verdict":"yes","reason":"符合汤面事实"}错误:只有出题人可以请求 AI 建议、AI 建议已关闭(Soup.AiAssist=false)、当前不在对局中、没有待判定的提问、AI 暂时无法给出建议,请手动判定。
soup_answer(出题人判定)
方向:C→S
{"type":"soup_answer","question_id":1,"verdict":"yes","reason":"符合"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
question_id | int | 是 | 目标问题 ID |
verdict | string | 是 | yes / no / unrelated / close / correct(correct 仅用于猜底命中) |
reason | string | 否 | 判定理由,最多 60 字符 |
广播:
{"type":"soup_verdict","question_id":1,"question":"他是意外死亡的吗?","verdict":"yes","reason":"符合"}若为猜底(kind="guess"),额外广播:
{"type":"soup_guess_result","question_id":2,"correct":true,"nickname":"小明"}命中(correct)即结算揭示。
错误:只有出题人可以判定、当前不在对局中、无效的判定、提问不存在、「命中」只能用于汤底猜测。
soup_guess(猜题人猜底)
方向:C→S
{"type":"soup_guess","text":"他其实没有死"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
text | string | 是 | 汤底猜测内容,最多 300 字符 |
错误:出题人不能猜底、当前不在对局中、汤底内容不能为空。
soup_giveup(出题人揭示汤底)
方向:C→S
{"type":"soup_giveup"}错误:只有出题人可以揭示汤底、当前不在对局中。
soup_hint(出题人给提示)
方向:C→S
{"type":"soup_hint"}广播:
{"type":"soup_hint","index":1,"text":"提示内容","total":3}错误:只有出题人可以给出提示、当前不在对局中、没有更多提示了(Soup.MaxHints,默认 3)。
soup_next(出题人开始下一题)
方向:C→S
{"type":"soup_next"}说明:仅结算状态可用;重置房间到 picking,广播 soup_room 与 soup_system「请出题人选择下一题」。 错误:只有出题人可以开始下一题、当前对局尚未结算。
5.5 服务器推送
soup_room(房间快照,按视角裁剪)
{
"type": "soup_room",
"data": {
"id": "ABCDEF",
"name": "我的汤面房",
"state": "playing",
"host_player_id": "player_xxx",
"host_nickname": "小明",
"max_players": 6,
"max_questions": 30,
"max_hints": 3,
"ai_assist": true,
"your_role": "guesser",
"is_host": false,
"members": [
{"player_id":"player_xxx","nickname":"小明","role":"host","is_host":true},
{"player_id":"player_yyy","nickname":"小红","role":"guesser","is_host":false}
],
"puzzle": {"id":1,"title":"...","surface":"...","difficulty":1,"tags":"..."},
"hints_revealed": 0,
"questions": [
{"id":1,"text":"...","nickname":"小红","kind":"ask","verdict":"yes","reason":"","ai":null}
]
}
}| 说明 |
|---|
puzzle 对猜题人只含 id/title/surface/difficulty/tags;对房主额外含 truth/key_points/hints |
questions[].ai 仅房主可见(soup_ai_suggest 结果) |
your_role:host / guesser |
soup_round(开局汤面)
{
"type": "soup_round",
"title": "...",
"surface": "...",
"difficulty": 1,
"tags": "...",
"host_nickname": "小明",
"max_questions": 30,
"max_hints": 3
}房主额外收到 truth、key_points、hints。
soup_won(命中公告)
{"type":"soup_won","nickname":"小红","solution":"汤底全文"}soup_over(结算)
{
"type": "soup_over",
"solution": "汤底全文",
"key_points": ["..."],
"hints": ["..."],
"winner": "小红",
"questions_used": 8,
"hints_used": 1,
"duration": 240
}| 字段 | 说明 |
|---|---|
winner | 命中者昵称;无人命中时为空字符串(房主胜) |
duration | 对局时长(秒) |
soup_system / soup_error
{"type":"soup_system","text":"..."}
{"type":"soup_error","message":"..."}6. 缘分(默契测试)
WebSocket 路径:/ws/fate(声明用)
Handler:FateWebSocketHandler
前缀:fate.
开关:Fate.Enabled(默认开启)
默契测试依附于 1v1 对局:客户端仍在原 /ws 对局连接上发送 fate.* 消息,由路由层 fate. 前缀捕获转交本 Handler。因此一般不会单独连接 /ws/fate。
6.1 连接建立
独立连接 /ws/fate 时:
{"type":"fate.connected"}6.2 消息协议
fate.accept(发起/接受默契邀请)
方向:C→S
{"type":"fate.accept"}本方响应:
{"type":"fate.accepted","data":{"session_id":"sess_xxx"}}对方收到:
{"type":"fate.invite","data":{"session_id":"sess_xxx","partner":"小明","timeout":30}}双方都确认 → 开题:
{"type":"fate.start","data":{"quiz":[...],"partner":"小红","max_queues":5}}| 字段 | 说明 |
|---|---|
quiz | 题目列表(Fate.QuizCount,默认 5 题) |
max_queues | 题目数量 |
错误:当前没有可进行默契测试的对局、身份异常,无法进入默契测试。
fate.decline(拒绝邀请)
方向:C→S
{"type":"fate.decline"}本方响应:
{"type":"fate.declined","data":{"session_id":"sess_xxx"}}对方收到:
{"type":"fate.opponent_done","data":{"status":"declined"}}fate.submit(提交答案与猜测)
方向:C→S
{"type":"fate.submit","answers":[0,1,2,3,0],"guesses":[1,1,2,3,0]}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
answers | int[] | 是 | 自己的选项(0-3),长度必须等于题目数 |
guesses | int[] | 否 | 对对方选项的猜测(0-3),缺失按未中处理 |
本方响应:
{"type":"fate.submitted","data":{"session_id":"sess_xxx"}}对方收到:
{"type":"fate.opponent_done","data":{"status":"submitted"}}双方都提交 → 出报告:
{"type":"fate.report","data":{"record_id":123,"session_id":"sess_xxx","report":{...}}}错误:当前不在答题状态、题目异常,请重试、请完成全部题目再提交。
fate.publish(官宣报告到聊天室)
方向:C→S
{"type":"fate.publish","record_id":123}响应:
{"type":"fate.published","data":{"record_id":123,"already_published":false}}| 字段 | 说明 |
|---|---|
already_published | 已官宣过的报告只回执不重复广播 |
说明:需登录身份,record_id 必须属于调用者本人;成功后向大厅聊天室广播 fate_card 缘分卡片。 错误:报告不存在、请先登录后再官宣、报告不存在或无权官宣、身份信息异常,无法官宣。
fate.history(历史报告)
方向:C→S
{"type":"fate.history"}响应:
{
"type": "fate.history_list",
"data": {
"records": [
{
"id": 123,
"nickname_a": "小明",
"nickname_b": "小红",
"score": 82,
"verdict": "...",
"lucken": "...",
"stats": {...},
"golds": [...],
"published": true,
"created_at": 1730000000
}
],
"total": 1
}
}6.3 断线处理
一方断开时,另一方收到:
{"type":"fate.opponent_done","data":{"status":"left"}}7. 方块世界(2D 沙盒)
WebSocket 路径:/ws/blockworld
Handler:BlockWorldWebSocketHandler
前缀:bw_
开关:BlockWorld.Enabled(默认关闭)
2D 多人沙盒世界。世界未加载就绪时拒绝连接。位置采用「客户端预测 + 服务端校验」:服务端不跑物理 tick,仅在收到上报时校验位移/碰撞;下行以 100ms(10Hz)批包聚合。
7.1 连接建立
服务端推送(连接成功后,要求世界已就绪):
{"type":"bw_connected"}若世界未就绪:{"type":"bw_error","data":"世界尚未就绪,请稍后再试"} 并关闭连接。
7.2 消息协议
bw_join(进入世界)
方向:C→S
{
"type": "bw_join",
"fp": "设备指纹",
"nickname": "小明",
"password": "密码",
"player_token": "已有 token",
"x": 100.5,
"y": 40.0
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
fp | string | 否 | 设备指纹 |
nickname | string | 是 | 昵称 |
password | string | 条件 | 新玩家/恢复身份时 |
player_token | string | 条件 | 已有 token 时 |
x / y | float | 否 | 期望出生点坐标(合法且无碰撞时采用,否则用世界默认出生点) |
响应:
{"type":"bw_joined","player_id":"player_xxx","pid_hash":1234567890,"spawn":{"x":100.5,"y":40.0},"world":{"w":256,"h":128}}
{"type":"bw_pid_map","map":{"1234567890":"player_xxx"},"nick":{"player_xxx":"小明"}}
{"type":"bw_chunk","cx":6,"cy":2,"data":"..."}世界广播(给其他玩家):
{"type":"bw_player_info","pidHash":1234567890,"pid":"player_xxx","nickname":"小明"}错误:世界人数已满,请稍后再试、您已被管理员封禁...、身份验证错误。
bw_move(位置上报)
方向:C→S
{"type":"bw_move","x":101.2,"y":40.0,"dir":2}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
x / y | float | 是 | 坐标 |
dir | int | 否 | 朝向(0-3) |
纠偏(位移超限/越界/穿墙时回拉到服务端权威位置):
{"type":"bw_correct","x":100.5,"y":40.0}错误:尚未进入世界。
bw_dig(挖掘)/ bw_place(放置)
方向:C→S
{"type":"bw_dig","x":100,"y":40}{"type":"bw_place","x":100,"y":40,"id":3}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
x / y | int | 是 | 方块坐标 |
id | int | 是(place) | 方块 ID |
广播:
{"type":"bw_block","x":100,"y":40,"id":3}错误:距自身超出 ReachDistance 等由服务端判定,返回 bw_error。
bw_chat(世界内聊天)
方向:C→S
{"type":"bw_chat","text":"你好"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
text | string | 是 | 消息内容,最多 100 字符,最小间隔 800ms |
广播:
{"type":"bw_chat","pidHash":1234567890,"nickname":"小明","text":"你好"}bw_respawn(重生)
方向:C→S
{"type":"bw_respawn"}响应:{"type":"bw_correct","x":...,"y":...}(回到世界默认出生点)
7.3 服务器推送
bw_pack(移动批包)
100ms 周期性下发视野内玩家位置,二进制 base64 信封:
{"type":"bw_pack","enc":"b64","data":"..."}- 仅视野内(
AOIViewChunks)且窗口内有位移的玩家进入批包;视野内仅 1 人时改发单条bw_moveJSON - 二进制格式:
[count:1B] + N × [pidHash:4B][x:2B][y:2B][flags:2B],坐标量化为 1/8 方块(uint16) flags:bit0-1 朝向,bit2-3 动作(V1 恒为 0),bit4 是否为观察者自己
bw_chunk / bw_block / bw_player_info / bw_leave / bw_error
{"type":"bw_chunk","cx":6,"cy":2,"data":"..."}
{"type":"bw_block","x":100,"y":40,"id":0}
{"type":"bw_player_info","pidHash":1234567890,"pid":"player_xxx","nickname":"小明"}
{"type":"bw_leave","pidHash":1234567890}
{"type":"bw_error","data":"错误描述"}7.4 断线处理
玩家离开时世界内广播 bw_leave(携带 pidHash)。
8. 管理后台 WebSocket
WebSocket 路径:/{Admin.Path}/ws(Admin.Path 默认 admin,即 /admin/ws)
Handler:AdminWebSocketHandler
8.1 连接与鉴权
连接建立后服务端推送:
{"type":"need_admin_login"}除 admin_connect 外,所有消息需先完成鉴权,否则返回 {"type":"error","message":"请先登录"}。
admin_connect(管理员鉴权)
方向:C→S
{"type":"admin_connect","token":"管理员token"}成功响应:
{"type":"admin_connected","username":"admin","role":"admin"}
{"type":"sessions_list","sessions":[{"id":"sess_xxx","player1":"小明","player2":"小红","state":"chatting"}]}失败:{"type":"error","message":"管理员验证失败"} / 管理员账号已被禁用或不存在,并关闭连接。
鉴权后服务端会推送在线管理列表:
{"type":"admin_online_list","online_list":[{"fd":10,"admin_id":1,"username":"admin","role":"admin","current_operation":"正在封禁玩家"}]}当某管理员开始/结束操作时推送:
{"type":"admin_status","fd":10,"username":"admin","operation":"正在封禁玩家"}8.2 消息类型总览
| 请求 type | 响应 type | 说明 |
|---|---|---|
admin_connect | admin_connected、sessions_list | 鉴权 |
admin_sessions | sessions_list | 拉取活跃对局列表 |
admin_ban_player | system | 封禁对局玩家 |
admin_ban_by_info | admin_banned_by_info | 按 IP/指纹封禁 |
admin_broadcast | broadcast {text,duration} | 全服公告 |
admin_room_broadcast | room_announce {text} | 房间公告 |
admin_spectate | session_detail {session_id,state,history,player1,player2} | 进入观战 |
admin_unspectate | admin_unspectated | 退出观战 |
admin_reports | admin_reports | 举报列表 |
admin_report_detail | admin_report_detail {report} | 举报详情 |
admin_mark_reviewed | admin_mark_reviewed_result | 标记举报已处理 |
admin_sticker_upload / admin_sticker_add | admin_sticker_added {sticker} | 上传/添加表情 |
admin_sticker_batch_add | admin_sticker_batch_added | 批量添加表情 |
admin_sticker_delete | admin_sticker_deleted {id} | 删除表情 |
admin_sticker_batch_delete | admin_sticker_batch_deleted | 批量删除表情 |
admin_sticker_list | admin_stickers_list {stickers} | 表情列表 |
admin_sticker_review_list | admin_sticker_review_list | 待审表情列表 |
admin_sticker_approve | admin_sticker_approved | 通过表情审核 |
admin_sticker_batch_approve | admin_sticker_batch_approved | 批量通过审核 |
admin_sticker_reject | admin_sticker_rejected | 拒绝表情审核 |
admin_sticker_batch_reject | admin_sticker_batch_rejected | 批量拒绝审核 |
admin_list | admin_list {admins} | 管理员列表 |
admin_add | admin_added {admin} | 添加管理员 |
admin_delete | admin_deleted {admin_id} | 删除管理员 |
admin_change_password / admin_own_password | admin_password_changed | 修改管理员密码 |
admin_my_logs | admin_my_logs | 我的操作日志 |
admin_all_logs | admin_all_logs | 全部操作日志 |
admin_lobby_players | admin_lobby_players | 聊天室在线玩家 |
admin_lobby_messages | admin_lobby_messages | 聊天室消息历史 |
admin_lobby_delete | lobby_message_deleted | 删除聊天室消息 |
admin_lobby_ban | system / error | 封禁聊天室玩家 |
admin_lobby_announce | room_announce | 聊天室公告 |
admin_lobby_rate_limit | lobby_rate_limit_info | 查询聊天室限流 |
admin_lobby_batch_delete | lobby_message_deleted | 批量删除消息 |
admin_lobby_batch_ban | — | 批量封禁聊天室玩家 |
admin_user_search | admin_user_search_result | 搜索用户 |
admin_user_ban | banned / opponent_banned / spectate_ended | 封禁用户 |
admin_user_unban | admin_user_unban_result {ip,fp,player_id} | 解封用户 |
admin_user_list_banned | admin_banned_list {records} | 封禁列表 |
community_posts | community_posts | 社区帖子列表 |
community_post_detail | community_post_detail | 帖子详情 |
community_post_review | community_post_review_result | 审核帖子 |
community_post_batch_review | community_post_batch_review_result | 批量审核帖子 |
community_post_pin | community_post_pin_result | 设置置顶 |
community_comments | community_comments | 评论列表 |
community_comment_review | community_comment_review_result | 审核评论 |
admin_user_get_tags | admin_user_tags_result | 查询用户标签 |
admin_user_set_special | admin_user_special_result | 设置特殊标签 |
admin_user_add_tag | admin_user_tag_added | 添加标签 |
admin_user_delete_tag | admin_user_tag_deleted | 删除标签 |
admin_bot_list | admin_bot_list_result {bots,total,page,page_size} | BOT 列表 |
admin_bot_add | admin_bot_add_result {ok,bot|error} | 添加 BOT |
admin_bot_set_status | admin_bot_status_result {ok,id,status} | 切换 BOT 状态 |
admin_bot_delete | admin_bot_delete_result {ok,id} | 删除 BOT |
admin_announce_list | admin_announce_list_result | 公告列表 |
admin_bot_apply_list | admin_bot_apply_list_result | BOT 申请列表 |
admin_bot_apply_review | admin_bot_apply_review_result | 审核 BOT 申请 |
admin_soup_rooms | admin_soup_rooms | 海龟汤房间列表 |
admin_soup_dissolve | — | 解散海龟汤房间 |
admin_soup_puzzles | admin_soup_puzzles | 汤面(题库)列表 |
admin_soup_puzzle_status | — | 更新汤面状态 |
admin_soup_puzzle_delete | — | 删除汤面 |
未识别的类型返回 {"type":"error","message":"未知的管理消息类型: X"}。
9. Bot Gateway WebSocket
WebSocket 路径:/bot
Handler:BotGatewayWebSocketHandler
Bot Gateway 是开放给第三方 BOT 客户端的 WebSocket 网关,BOT 可以以独立身份加入聊天室。BOT 连接建立后需要先鉴权,鉴权成功后自动加入聊天室。
9.1 鉴权流程
客户端 → 服务端: 连接 /bot
服务端 → 客户端: {"type":"bot_wait_auth"}
客户端 → 服务端: {"type":"bot_auth","player_id":"xx","key":"xx"}
服务端 → 客户端: {"type":"bot_auth_ok","nickname":"xx"} 或 {"type":"bot_error","message":"..."}步骤说明:
- 客户端连接
ws://host/bot/ - 服务端返回
{"type":"bot_wait_auth"},等待鉴权 - 客户端发送鉴权消息
bot_auth - 服务端校验
player_id+key是否匹配bot_list表且状态启用 - 鉴权成功 → 自动以 BOT 身份加入聊天室 + 返回
bot_auth_ok - 鉴权失败 → 返回
bot_error并关闭连接
未鉴权时仅接受
bot_auth,其它消息一律返回{"type":"bot_error","message":"请先发送鉴权消息 bot_auth"}并关闭连接。
9.2 消息协议
bot_auth(鉴权)
方向:C→S
{
"type": "bot_auth",
"player_id": "玩家ID",
"key": "bot_key"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
player_id | string | 是 | 绑定 BOT 的玩家 ID(从 BOT 面板获取) |
key | string | 是 | BOT 鉴权密钥(从 BOT 面板获取) |
bot_auth_ok(鉴权成功)
方向:S→C
{
"type": "bot_auth_ok",
"nickname": "我的机器人"
}| 字段 | 说明 |
|---|---|
nickname | BOT 在聊天室中的昵称 |
bot_wait_auth(等待鉴权)
方向:S→C
{"type": "bot_wait_auth"}bot_error(鉴权失败)
方向:S→C
{"type": "bot_error", "message": "BOT 鉴权失败:玩家ID或KEY不正确"}bot_ping / bot_pong(心跳)
方向:C→S → S→C
{"type": "bot_ping"}响应:
{"type": "bot_pong"}说明:BOT 客户端应每 30 秒发送一次 bot_ping 保持连接。
9.3 鉴权后的消息
鉴权成功后,BOT 自动加入聊天室,所有消息直传聊天室(复用 LobbyChatWebSocketHandler 的全部能力)。BOT 发送的消息会自动携带 _bot 标记,前端据此识别为 BOT 消息。
BOT 可发送的消息类型:lobby_chat、lobby_sticker、lobby_nudge 等(与聊天室协议一致,见 2.2 节)。
注意:BOT 客户端无需发送 lobby_join,服务端在鉴权成功后已自动调用;客户端重复发送 lobby_join 会被忽略。
10. 临时聊天 TempChat WebSocket
WebSocket 路径:/ws/tempchat
Handler:TempChatWebSocketHandler
前缀:temp_
临时聊天(1v1 私密房)系统,支持邀请制、一次性房间、断线重连等功能。强制要求登录账号(无有效 token 直接拒绝),不支持游客。
10.1 连接建立
连接建立后不发送额外消息,客户端需发送 temp_join 加入临时聊天。
10.2 消息协议
temp_join(加入临时聊天)
方向:C→S
{
"type": "temp_join",
"nickname": "小明",
"player_token": "eyJ..."
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
nickname | string | 否 | 显示昵称(服务端以账号昵称为准),最多 12 字符 |
player_token | string | 是 | 玩家 Token(必须登录,禁止游客) |
说明:临时聊天强制要求登录账号,无有效 token 将被拒绝并关闭连接。
错误(temp_error 后关闭连接):临时聊天需要登录账号,请先登录、登录已失效,请重新登录、昵称无效,无法加入临时聊天。
响应:
{
"type": "temp_joined",
"nickname": "小明",
"has_identity": true,
"player_id": "player_xxx"
}temp_search(搜索在线用户)
方向:C→S
{"type": "temp_search", "keyword": "小"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
keyword | string | 是 | 搜索关键词,最大 20 字符 |
响应:
{
"type": "temp_search_result",
"users": [
{"player_id": "abc...", "nickname": "小红", "status": "online"}
]
}temp_invite(发起邀请)
方向:C→S
{"type": "temp_invite", "target_player_id": "abc..."}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
target_player_id | string | 是 | 目标玩家 ID |
响应:
{"type": "temp_invite_sent", "ok": true, "invite_id": "iv_xxx"}错误:
{"type": "temp_invite_sent", "ok": false, "error": "对方不在线"}| error 文案 | 触发场景 |
|---|---|
缺少目标用户 | 目标 ID 为空 |
不能邀请自己 | 邀请自己 |
对方不在线 | 目标不在线 / fd 失效 |
对方正在对局中,无法邀请 | 目标状态 ingame |
对方正在临时聊天中 | 目标状态 busy |
被邀请方收到:
{
"type": "temp_invite",
"invite_id": "iv_xxx",
"from_name": "小明",
"from_player_id": "abc...",
"timeout": 60
}说明:邀请有效期 60 秒,超时自动取消。超时后邀请方收到 {"type":"temp_invite_result","ok":false,"error":"邀请超时,对方未做出回应"},被邀请方收到 {"type":"temp_invite_expired","text":"邀请已过期"}。
temp_invite_resp(回应邀请)
方向:C→S
{"type": "temp_invite_resp", "invite_id": "iv_xxx", "accept": true}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
invite_id | string | 是 | 邀请 ID |
accept | bool | 是 | true=接受,false=拒绝 |
错误(temp_invite_result):邀请不存在或已过期、无权回应此邀请。
拒绝:
- 邀请方收到:
{"type":"temp_invite_result","ok":false,"error":"对方拒绝了您的邀请"} - 被邀请方收到:
{"type":"temp_invite_dismissed"}
接受(双方进入房间):
邀请方收到(pending_join=true 表示邀请方不在临时聊天连接上,需跳转 /temp-chat 后发送 temp_rejoin 进房):
{
"type": "temp_room_created",
"room_id": "tr_xxx",
"peer_name": "对方昵称",
"peer_player_id": "对方ID",
"pending_join": false
}被邀请方收到:
{
"type": "temp_room_created",
"room_id": "tr_xxx",
"peer_name": "对方昵称",
"peer_player_id": "对方ID"
}错误(temp_invite_result):对方状态已变化,无法进入。
temp_rejoin(断线重连)
方向:C→S
{
"type": "temp_rejoin",
"room_id": "tr_xxx",
"nickname": "小明",
"player_token": "eyJ..."
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
room_id | string | 是 | 房间 ID |
nickname | string | 否 | 重连后显示的昵称 |
player_token | string | 是 | 玩家 Token(必须匹配房间中的身份) |
说明:断线 60 秒内可重连恢复。重连必须凭 token 匹配房间中的 a/b 一方身份,禁止纯昵称匹配(防冒认)。
响应:
{
"type": "temp_room_created",
"room_id": "tr_xxx",
"peer_name": "对方昵称",
"peer_player_id": "对方ID",
"rejoined": true,
"history": [{"sender_name":"小明","content":"你好","time":1730000000}]
}| 字段 | 说明 |
|---|---|
rejoined | 标记为重连恢复 |
history | 最近 50 条消息历史 |
错误(temp_closed):房间已关闭、房间校验失败。
对方收到:
{"type": "temp_system", "text": "小明 已重新连接"}temp_history(加载更早消息)
方向:C→S
{"type": "temp_history", "offset": 50}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
offset | int | 是 | 已加载条数(向上滚动加载更早消息) |
响应:
{
"type": "temp_history",
"messages": [{"sender_name":"小明","content":"...","time":1730000000}],
"offset": 75,
"has_more": true
}| 字段 | 说明 |
|---|---|
has_more | 是否还有更多历史消息 |
temp_chat(发送消息)
方向:C→S
{
"type": "temp_chat",
"content": "你好!",
"sticker_id": "表情ID",
"sticker_url": "表情URL"
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
content | string | 条件 | 文本内容,最大 1000 字符 |
sticker_id | string | 否 | 表情 ID |
sticker_url | string | 否 | 表情 URL(缺失时服务端按 sticker_id 解析) |
说明:content、sticker_id、sticker_url 至少填一个。消息写入 Redis LIST(保留最近 500 条,TTL 7200 秒,房间关闭即删)。
广播(双方收到):
{
"type": "temp_chat",
"sender_name": "小明",
"content": "你好!",
"time": 1730000000,
"player_id": "player_xxx",
"avatar": "https://.../avatar.png",
"sticker_id": "表情ID",
"sticker_url": "表情URL"
}| 字段 | 说明 |
|---|---|
time | Unix 秒级时间戳 |
player_id / avatar | 仅账号玩家携带 |
sticker_id / sticker_url | 表情消息携带 |
若房间处于非 active 状态,返回 {"type":"temp_system","text":"房间不存在或已关闭"}。
get_stickers(获取表情)
方向:C→S
{"type":"get_stickers"}响应:与主游戏/聊天室通用表情协议一致。
temp_exit(退出房间)
方向:C→S
{"type": "temp_exit"}说明:主动退出房间,房间自动关闭。对方收到:
{"type": "temp_closed", "reason": "对方退出了房间,房间已自动关闭", "text": "对方退出了房间,房间已自动关闭"}退出方自己收到:
{"type": "temp_closed", "reason": "已退出房间", "text": "已退出房间"}temp_report(举报)
方向:C→S
{"type": "temp_report", "reason": "不当言论"}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
reason | string | 是 | 举报原因,最多 500 字符 |
响应:
{"type": "temp_report_result", "ok": true, "message": "举报已提交,将交由管理员审理"}错误(temp_report_result):房间不存在、请填写举报原因。
temp_ping / temp_pong(心跳)
方向:C→S → S→C
{"type": "temp_ping"}响应:
{"type": "temp_pong"}10.3 服务器主动推送
| type | 说明 | 关键字段 |
|---|---|---|
temp_joined | 加入成功 | nickname、has_identity、player_id |
temp_search_result | 搜索结果 | users[] |
temp_invite | 收到邀请 | invite_id、from_name、from_player_id、timeout |
temp_invite_sent | 邀请发送结果 | ok、invite_id、error |
temp_invite_result | 邀请处理结果 | ok、error |
temp_invite_expired | 邀请已过期 | text |
temp_invite_dismissed | 邀请已关闭 | — |
temp_room_created | 进入房间/重连 | room_id、peer_name、peer_player_id、pending_join、rejoined、history[] |
temp_chat | 聊天消息(广播) | sender_name、content、sticker_id、sticker_url、time、player_id、avatar |
temp_history | 历史消息加载 | messages[]、offset、has_more |
temp_system | 系统提示 | text |
temp_closed | 房间关闭 | reason、text |
temp_report_result | 举报结果 | ok、message、error |
temp_error | 错误提示 | text |
temp_pong | 心跳响应 | — |
10.4 断线处理
- 断线 60 秒内可重连恢复(通过
temp_rejoin);对方收到{"type":"temp_system","text":"X 断开了连接,60秒内未重新连接将自动关闭房间"} - 重连时下发最近 50 条消息,更早的可向上滚动加载
- 超时未重连自动关房(
对方超时未重连,房间已自动关闭),消息不保存 - 双方均离线时立即关房(
双方均离线,房间已自动关闭)
附录:WebSocket 路径与 Handler 对照表
各 Handler 源码均可在 GitHub 仓库 的 App/Core/WebSocket 目录查看。
| WebSocket 路径 | Handler | 前缀 | 说明 | 源码 |
|---|---|---|---|---|
/ws | GameWebSocketHandler | 无(兜底) | "对面是AI吗" 主游戏 | GameWebSocketHandler.php |
/ws/lobby | LobbyChatWebSocketHandler | lobby_ | 大厅聊天室 | LobbyChatWebSocketHandler.php |
/ws/board/gomoku | BoardGame\GomokuHandler | board_gomoku_ | 五子棋(路径与前缀由 gameKey() 动态拼装) | GomokuHandler.php |
/ws/board/go | BoardGame\GoHandler | board_go_ | 围棋(路径与前缀由 gameKey() 动态拼装) | GoHandler.php |
/ws/soup | Soup\SoupWebSocketHandler | soup_ | 海龟汤(Soup.Enabled) | SoupWebSocketHandler.php |
/ws/fate | Fate\FateWebSocketHandler | fate. | 缘分/默契测试(Fate.Enabled,实际复用 /ws 连接) | FateWebSocketHandler.php |
/ws/blockworld | BlockWorld\BlockWorldWebSocketHandler | bw_ | 2D 方块世界(BlockWorld.Enabled) | BlockWorldWebSocketHandler.php |
/ws/tempchat | TempChatWebSocketHandler | temp_ | 临时聊天(1v1 私密房) | TempChatWebSocketHandler.php |
/bot | BotGatewayWebSocketHandler | — | BOT 网关(开放给第三方 BOT,复用 lobby_*) | BotGatewayWebSocketHandler.php |
/{Admin.Path}/ws(默认 /admin/ws) | Admin\AdminWebSocketHandler | admin_ | 管理后台 | AdminWebSocketHandler.php |
路由说明:
onOpen按请求路径匹配 Handler(未匹配时回退到/ws);onMessage按消息type前缀匹配,空前缀 Handler 作为兜底最后匹配。棋类前缀由AbstractBoardGameHandler::gameKey()动态拼装为board_{gameKey}_,新增棋类只需实现gameKey()。Admin.Path由Config/App.php配置(默认admin)。
所有端点共用的身份认证在 BaseGameHandler.php 中统一实现。