557 lines
20 KiB
Go
557 lines
20 KiB
Go
// Package room 提供玩法无关的房间容器:成员与座位、状态机、事件串行化、下发与超时回收。
|
|
// 房间只调用 game.Game 接口,不知道任何具体玩法规则。
|
|
package room
|
|
|
|
import (
|
|
"context" // 标准库:房间事件循环的生命周期上下文
|
|
"errors" // 标准库:房间不可加入、动作非法等错误
|
|
"log/slog" // 标准库:记录结算与关闭日志
|
|
"strings"
|
|
"sync" // 标准库:保护成员表与关闭幂等
|
|
"time" // 标准库:空闲超时、掉线宽限、tick 周期
|
|
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/game"
|
|
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/wsgateway"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Status 是房间状态。
|
|
type Status string
|
|
|
|
const (
|
|
StatusWaiting Status = "waiting" // 等待玩家到齐/准备
|
|
StatusReady Status = "ready" // 人数达标且全员准备
|
|
StatusPlaying Status = "playing" // 对局进行中
|
|
StatusPaused Status = "paused" // 有人掉线,重连宽限中
|
|
StatusFinished Status = "finished" // 已分出结果
|
|
StatusClosed Status = "closed" // 已关闭,不再可用
|
|
)
|
|
|
|
// roomTick 是房间事件循环的 tick 周期:驱动实时玩法与各类超时检查。
|
|
const roomTick = time.Second
|
|
|
|
// member 是房间内一名成员。
|
|
type member struct {
|
|
uid int64 // 用户 ID
|
|
seat int // 座位下标
|
|
ready bool // 是否已准备
|
|
online bool // 是否在线
|
|
offlineAt time.Time // 掉线时刻,用于宽限倒计时
|
|
}
|
|
|
|
// message 是投递到房间事件循环的一次请求。
|
|
type message struct {
|
|
kind string // 请求类型:join/leave/ready/action/reconnect/offline/close
|
|
uid int64 // 发起用户
|
|
action *game.Action // 玩法动作(仅 action 用)
|
|
ready bool // 准备状态(仅 ready 用)
|
|
reason string // 关闭原因(仅 close 用)
|
|
result chan error // 同步回传结果,便于 handler 直接回错误
|
|
}
|
|
|
|
// Room 是一局游戏的容器。所有状态变更都经由 reqCh 串行处理,玩法实现因此无需加锁。
|
|
type Room struct {
|
|
id string // 房间 ID
|
|
gameID string // 游戏 ID
|
|
g game.Game // 玩法实例
|
|
state game.State // 玩法私有状态(透传)
|
|
seats []int64 // 座位表:下标即座位号,0 表示空位
|
|
members map[int64]*member // userID -> 成员
|
|
status Status // 当前状态
|
|
reqCh chan message // 请求队列
|
|
closed chan struct{} // 关闭信号
|
|
closeOnce sync.Once // 关闭幂等
|
|
hub *wsgateway.Hub // 下发通道
|
|
onClosed func(string, []int64) // 关闭回调:通知 Manager 解绑
|
|
lastActive time.Time // 最近活跃时间,用于空闲回收
|
|
finish *game.Finish // 对局结果
|
|
tickInterval time.Duration // 事件循环驱动周期:实时玩法按 Meta.TickMs 加速
|
|
roster []dto.PlayerBrief // 玩家名册(昵称/头像/座位):客户端据此渲染,不含任何 AI 标记
|
|
}
|
|
|
|
// newRoom 创建房间并启动事件循环。
|
|
func newRoom(id, gameID string, seats int, roster []dto.PlayerBrief, hub *wsgateway.Hub, onClosed func(string, []int64)) (*Room, error) {
|
|
g, err := game.New(gameID) // 按 ID 建玩法实例
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
meta := g.Meta() // 取玩法元信息
|
|
st, err := g.Init(seats) // 按本局实际人数初始化状态
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 驱动周期:实时玩法通过 Meta.TickMs 指定(如贪吃蛇 200ms),回合制沿用默认 1 秒。
|
|
tickInterval := roomTick
|
|
if meta.TickMs > 0 {
|
|
tickInterval = time.Duration(meta.TickMs) * time.Millisecond
|
|
}
|
|
|
|
r := &Room{
|
|
id: id, // 房间 ID
|
|
gameID: gameID, // 游戏 ID
|
|
g: g, // 玩法实例
|
|
state: st, // 玩法状态
|
|
seats: make([]int64, seats), // 按本局人数建座位表
|
|
members: make(map[int64]*member), // 成员表
|
|
status: StatusWaiting, // 初始为等待
|
|
reqCh: make(chan message, 64), // 请求缓冲队列
|
|
closed: make(chan struct{}), // 关闭信号
|
|
hub: hub, // 下发通道
|
|
onClosed: onClosed, // 关闭回调
|
|
lastActive: time.Now(), // 初始活跃时间
|
|
roster: roster, // 玩家名册(含 AI,对外无标识)
|
|
tickInterval: tickInterval, // 驱动周期(实时玩法更快)
|
|
}
|
|
go r.run() // 启动单 goroutine 事件循环
|
|
return r, nil
|
|
}
|
|
|
|
// ID 返回房间 ID。
|
|
func (r *Room) ID() string { return r.id }
|
|
|
|
// GameID 返回游戏 ID。
|
|
func (r *Room) GameID() string { return r.gameID }
|
|
|
|
// Status 返回房间状态。
|
|
func (r *Room) Status() Status { return r.status }
|
|
|
|
// UserIDs 返回房间内全部用户 ID。
|
|
func (r *Room) UserIDs() []int64 {
|
|
uids := make([]int64, 0, len(r.members)) // 预分配
|
|
for uid := range r.members { // 遍历成员表
|
|
uids = append(uids, uid) // 收集
|
|
}
|
|
return uids
|
|
}
|
|
|
|
// Has 判断用户是否在房间内。
|
|
func (r *Room) Has(uid int64) bool {
|
|
_, ok := r.members[uid] // 查成员表
|
|
return ok
|
|
}
|
|
|
|
// Join 入座(同步等待事件循环处理结果)。
|
|
func (r *Room) Join(uid int64) error {
|
|
return r.submit(message{kind: "join", uid: uid}) // 提交入座请求
|
|
}
|
|
|
|
// Leave 离座。
|
|
func (r *Room) Leave(uid int64) error {
|
|
return r.submit(message{kind: "leave", uid: uid}) // 提交离座请求
|
|
}
|
|
|
|
// Ready 设置准备状态。
|
|
func (r *Room) Ready(uid int64, ready bool) error {
|
|
return r.submit(message{kind: "ready", uid: uid, ready: ready}) // 提交准备请求
|
|
}
|
|
|
|
// Submit 提交一次玩法动作。
|
|
func (r *Room) Submit(uid int64, a game.Action) error {
|
|
act := a // 拷贝动作,避免调用方后续修改
|
|
return r.submit(message{kind: "action", uid: uid, action: &act}) // 提交动作请求
|
|
}
|
|
|
|
// Reconnect 标记用户重新在线。
|
|
func (r *Room) Reconnect(uid int64) error {
|
|
return r.submit(message{kind: "reconnect", uid: uid}) // 提交重连请求
|
|
}
|
|
|
|
// MarkOffline 标记用户掉线:由 Manager 在收到 Hub 断连回调时调用。
|
|
func (r *Room) MarkOffline(uid int64) error {
|
|
return r.submit(message{kind: "offline", uid: uid}) // 提交掉线请求
|
|
}
|
|
|
|
// Close 关闭房间:走事件循环串行化,避免在别的 goroutine 里直接改状态。
|
|
func (r *Room) Close(reason string) {
|
|
_ = r.submit(message{kind: "close", reason: reason}) // 提交关闭请求
|
|
}
|
|
|
|
// submit 把请求投递到事件循环并等待结果。
|
|
func (r *Room) submit(m message) error {
|
|
m.result = make(chan error, 1) // 建结果通道
|
|
select {
|
|
case <-r.closed: // 房间已关闭
|
|
return errors.New("room closed")
|
|
case r.reqCh <- m: // 投递成功
|
|
}
|
|
select {
|
|
case err := <-m.result: // 等待处理结果
|
|
return err
|
|
case <-time.After(3 * time.Second): // 事件循环繁忙
|
|
return errors.New("room busy")
|
|
}
|
|
}
|
|
|
|
// run 是房间事件循环:串行处理请求与 tick,保证玩法调用无并发。
|
|
func (r *Room) run() {
|
|
ticker := time.NewTicker(r.tickInterval) // 按玩法规定的周期驱动(回合制 1 秒,实时玩法更快)
|
|
defer ticker.Stop() // 退出时停止定时器
|
|
|
|
for { // 持续处理
|
|
select {
|
|
case <-r.closed: // 房间已关闭
|
|
return
|
|
case m := <-r.reqCh: // 收到请求
|
|
r.handle(m)
|
|
case <-ticker.C: // 每秒驱动
|
|
r.tick(time.Now())
|
|
}
|
|
}
|
|
}
|
|
|
|
// handle 按类型分发请求,并把结果写回 result 通道。
|
|
func (r *Room) handle(m message) {
|
|
var err error // 处理结果
|
|
switch m.kind { // 按请求类型分发
|
|
case "join":
|
|
err = r.handleJoin(m.uid)
|
|
case "leave":
|
|
err = r.handleLeave(m.uid)
|
|
case "ready":
|
|
err = r.handleReady(m.uid, m.ready)
|
|
case "action":
|
|
err = r.handleAction(m.uid, m.action)
|
|
case "reconnect":
|
|
err = r.handleReconnect(m.uid)
|
|
case "offline":
|
|
err = r.handleOffline(m.uid)
|
|
case "close":
|
|
r.close(m.reason) // 关闭在循环内执行,状态修改保持串行
|
|
}
|
|
if m.result != nil { // 有结果通道
|
|
m.result <- err // 回传结果(带缓冲,不会阻塞)
|
|
}
|
|
}
|
|
|
|
// handleJoin 处理入座:分配座位并通知玩法。
|
|
func (r *Room) handleJoin(uid int64) error {
|
|
if r.status == StatusClosed || r.status == StatusFinished { // 房间不可用
|
|
return errors.New("room not joinable")
|
|
}
|
|
if m, ok := r.members[uid]; ok { // 已在房间内
|
|
m.online = true // 标记为在线
|
|
m.offlineAt = time.Time{} // 清空掉线时间
|
|
r.broadcastState() // 刷新快照
|
|
return nil
|
|
}
|
|
|
|
seat := -1 // 找空位
|
|
for i, s := range r.seats { // 遍历座位表
|
|
if s == 0 { // 空位
|
|
seat = i // 记录下标
|
|
break
|
|
}
|
|
}
|
|
if seat < 0 { // 无空位
|
|
return errors.New("room is full")
|
|
}
|
|
|
|
r.seats[seat] = uid // 占座
|
|
r.members[uid] = &member{uid: uid, seat: seat, online: true, ready: game.IsAIUID(uid)} // 登记成员(AI 入座即自动准备)
|
|
r.lastActive = time.Now() // 刷新活跃时间
|
|
|
|
events, err := r.g.OnJoin(uid, r.state) // 通知玩法
|
|
if err != nil { // 玩法拒绝(如人数已满)
|
|
delete(r.members, uid) // 回滚成员
|
|
r.seats[seat] = 0 // 回滚座位
|
|
return err
|
|
}
|
|
r.deliver(events) // 投递玩法事件
|
|
r.broadcastState() // 广播最新快照
|
|
return nil
|
|
}
|
|
|
|
// handleLeave 处理离座:通知玩法,玩法可据此判负。
|
|
func (r *Room) handleLeave(uid int64) error {
|
|
m, ok := r.members[uid] // 查成员
|
|
if !ok { // 不在房间
|
|
return errors.New("not in room")
|
|
}
|
|
delete(r.members, uid) // 移除成员
|
|
if m.seat < len(r.seats) && r.seats[m.seat] == uid { // 释放座位
|
|
r.seats[m.seat] = 0
|
|
}
|
|
|
|
events, finish, err := r.g.OnLeave(uid, r.state) // 通知玩法
|
|
if err == nil { // 玩法处理成功
|
|
r.deliver(events) // 投递事件
|
|
if finish != nil && r.status == StatusPlaying { // 玩法判定结束(如判对手胜)
|
|
r.finishGame(finish) // 结算
|
|
return nil
|
|
}
|
|
}
|
|
r.broadcastState() // 广播快照
|
|
return nil
|
|
}
|
|
|
|
// handleReady 处理准备:全员准备且人数达标即开局。
|
|
func (r *Room) handleReady(uid int64, ready bool) error {
|
|
if r.status != StatusWaiting && r.status != StatusReady { // 仅等待阶段可准备
|
|
return errors.New("room not in waiting status")
|
|
}
|
|
m, ok := r.members[uid] // 查成员
|
|
if !ok { // 不在房间
|
|
return errors.New("not in room")
|
|
}
|
|
m.ready = ready // 设置准备状态
|
|
r.lastActive = time.Now() // 刷新活跃时间
|
|
|
|
if r.allReady() && len(r.members) == len(r.seats) { // 全员准备且坐满
|
|
r.status = StatusReady // 进入就绪
|
|
r.startGame() // 立即开局
|
|
} else {
|
|
r.status = StatusWaiting // 仍在等待
|
|
r.broadcastState() // 广播快照
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleAction 处理玩法动作:透传给玩法插件。
|
|
func (r *Room) handleAction(uid int64, a *game.Action) error {
|
|
if r.status != StatusPlaying { // 未开局
|
|
return errors.New("game not playing")
|
|
}
|
|
if _, ok := r.members[uid]; !ok { // 非本局成员
|
|
return errors.New("not in room")
|
|
}
|
|
if a == nil { // 缺动作
|
|
return errors.New("empty action")
|
|
}
|
|
|
|
events, finish, err := r.g.OnAction(uid, *a, r.state) // 交给玩法处理
|
|
if err != nil { // 玩法拒绝(如不是你的回合)
|
|
return err
|
|
}
|
|
r.lastActive = time.Now() // 刷新活跃时间
|
|
r.deliver(events) // 投递增量事件
|
|
|
|
if finish != nil { // 对局结束
|
|
r.finishGame(finish)
|
|
} else { // 未结束
|
|
r.broadcastState() // 广播快照
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// handleReconnect 处理重连:恢复在线并回到暂停中的对局。
|
|
func (r *Room) handleReconnect(uid int64) error {
|
|
m, ok := r.members[uid] // 查成员
|
|
if !ok { // 不在房间(可能已被清理)
|
|
return errors.New("not in room")
|
|
}
|
|
m.online = true // 标记在线
|
|
m.offlineAt = time.Time{} // 清空掉线时间
|
|
r.lastActive = time.Now() // 刷新活跃时间
|
|
|
|
if r.status == StatusPaused { // 从暂停恢复
|
|
r.status = StatusPlaying
|
|
}
|
|
r.broadcastState() // 下发全量快照(客户端据此恢复画面)
|
|
return nil
|
|
}
|
|
|
|
// handleOffline 处理掉线:标记离线,对局中则暂停等待重连(宽限超时由 tick 判负)。
|
|
func (r *Room) handleOffline(uid int64) error {
|
|
m, ok := r.members[uid] // 查成员
|
|
if !ok { // 不在房间
|
|
return errors.New("not in room")
|
|
}
|
|
m.online = false // 标记为离线
|
|
m.offlineAt = time.Now() // 记录掉线时刻,宽限倒计时起点
|
|
|
|
if r.status == StatusPlaying { // 对局进行中有人掉线
|
|
r.status = StatusPaused // 进入暂停,等待重连或超时判负
|
|
}
|
|
r.broadcastState() // 广播,让其他人看到掉线状态
|
|
return nil
|
|
}
|
|
|
|
// startGame 开局:切换状态并广播。
|
|
func (r *Room) startGame() {
|
|
r.status = StatusPlaying // 进入对局中
|
|
slog.Info("room game started", "room_id", r.id, "game_id", r.gameID)
|
|
r.broadcastState() // 广播开局快照
|
|
}
|
|
|
|
// finishGame 结算:记录结果、广播结算、延时关闭房间。
|
|
func (r *Room) finishGame(f *game.Finish) {
|
|
r.status = StatusFinished // 进入已结束
|
|
r.finish = f // 保存结果
|
|
|
|
// TODO(后续):结算落库(对局记录表由你自行准备,可在 internal/mysqldao 新增 room_record DAO)。
|
|
slog.Info("room game finished", "room_id", r.id, "game_id", r.gameID,
|
|
"winner", f.Winner, "reason", f.Reason)
|
|
|
|
r.broadcast(wsgateway.TypeRoomFinished, dto.WSRoomFinishedPayload{ // 广播结算
|
|
RoomID: r.id, GameID: r.gameID, Winner: f.Winner, Players: r.roster, Scores: f.Scores, Reason: f.Reason,
|
|
})
|
|
|
|
go func() { // 延时关闭,给客户端留出查看结算的时间
|
|
time.Sleep(10 * time.Second) // 等待 10 秒
|
|
r.Close("finished") // 经事件循环关闭,避免跨 goroutine 改状态
|
|
}()
|
|
}
|
|
|
|
// tick 每秒驱动:空闲回收、掉线宽限判负、实时玩法 tick。
|
|
func (r *Room) tick(now time.Time) {
|
|
if r.status == StatusClosed { // 已关闭
|
|
return
|
|
}
|
|
|
|
// 空闲回收:等待/就绪阶段长时间无操作则关闭,避免僵尸房间占用内存。
|
|
if r.status == StatusWaiting || r.status == StatusReady {
|
|
if now.Sub(r.lastActive) > idleTimeout() { // 超过空闲上限
|
|
slog.Info("room idle timeout", "room_id", r.id)
|
|
r.close("idle") // 关闭
|
|
return
|
|
}
|
|
}
|
|
|
|
// 掉线宽限:暂停期间有人未在宽限期内重连,判超时结束。
|
|
if r.status == StatusPaused {
|
|
for _, m := range r.members { // 检查每个成员
|
|
if !m.online && now.Sub(m.offlineAt) > reconnectGrace() { // 超时未重连
|
|
var winner int64 // 默认平局
|
|
for _, other := range r.members { // 找仍在线的对手
|
|
if other.online { // 在线者判胜
|
|
winner = other.uid
|
|
break
|
|
}
|
|
}
|
|
slog.Info("room reconnect timeout", "room_id", r.id, "user_id", m.uid, "winner", winner)
|
|
r.finishGame(&game.Finish{Winner: winner, Reason: "timeout"}) // 判超时结束
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// 实时玩法驱动:每秒把时间交给玩法(回合制玩法可实现为空)。
|
|
if r.status == StatusPlaying {
|
|
r.driveAI(now.Unix()) // 先让 AI 玩家行动(对外不可感知)
|
|
if r.status != StatusPlaying { // AI 行动后可能已分出胜负
|
|
return
|
|
}
|
|
events, finish, err := r.g.OnTick(now.Unix(), r.state) // 调用玩法 tick
|
|
if err != nil { // 玩法内部错误
|
|
slog.Error("room game tick error", "room_id", r.id, "error", err)
|
|
return
|
|
}
|
|
r.deliver(events) // 投递事件
|
|
if finish != nil { // 玩法判定结束
|
|
r.finishGame(finish)
|
|
}
|
|
}
|
|
}
|
|
|
|
// close 关闭房间:广播关闭并回调 Manager 解绑。
|
|
func (r *Room) close(reason string) {
|
|
r.closeOnce.Do(func() { // 幂等:只执行一次
|
|
r.status = StatusClosed // 置为已关闭
|
|
close(r.closed) // 通知事件循环退出
|
|
uids := r.UserIDs() // 先取成员列表
|
|
r.broadcast(wsgateway.TypeRoomClosed, dto.WSRoomClosedPayload{RoomID: r.id, Reason: reason})
|
|
if r.onClosed != nil { // 有回调
|
|
r.onClosed(r.id, uids) // 通知 Manager 清理
|
|
}
|
|
slog.Info("room closed", "room_id", r.id, "reason", reason)
|
|
})
|
|
}
|
|
|
|
// allReady 判断是否全员已准备。
|
|
func (r *Room) allReady() bool {
|
|
if len(r.members) != len(r.seats) { // 人数未达标
|
|
return false
|
|
}
|
|
for _, m := range r.members { // 遍历成员
|
|
if !m.ready { // 有人未准备
|
|
return false
|
|
}
|
|
}
|
|
return true // 全员准备
|
|
}
|
|
|
|
// broadcast 向房间全体下发一条消息。
|
|
func (r *Room) broadcast(typ string, payload any) {
|
|
data, err := wsgateway.Encode(typ, 0, payload) // 编码信封
|
|
if err != nil { // 编码失败
|
|
slog.Error("room encode failed", "room_id", r.id, "error", err)
|
|
return
|
|
}
|
|
r.hub.Broadcast(r.UserIDs(), data) // 经 Hub 下发
|
|
}
|
|
|
|
// broadcastState 给每个成员单独下发「对其可见」的房间快照。
|
|
func (r *Room) broadcastState() {
|
|
for uid := range r.members { // 逐个成员生成快照
|
|
snap := r.g.Snapshot(uid, r.state) // 玩法按观众裁剪
|
|
data, err := wsgateway.Encode(wsgateway.TypeRoomState, 0, dto.WSRoomStatePayload{
|
|
RoomID: r.id, GameID: r.gameID, Status: string(r.status), Seats: r.seats,
|
|
Ready: r.readyIDs(), State: snap, Players: r.roster,
|
|
})
|
|
if err != nil { // 编码失败
|
|
slog.Error("room encode state failed", "room_id", r.id, "error", err)
|
|
continue
|
|
}
|
|
r.hub.SendTo(uid, data) // 单独下发
|
|
}
|
|
}
|
|
|
|
// deliver 投递玩法产生的事件:未指定收件人则全体广播。
|
|
func (r *Room) deliver(events []game.Event) {
|
|
if len(events) == 0 { // 无事件
|
|
return
|
|
}
|
|
for _, e := range events { // 逐个事件下发
|
|
data, err := wsgateway.Encode(wsgateway.TypeRoomEvent, 0, dto.WSRoomEventPayload{
|
|
RoomID: r.id, Event: e.Name, Payload: e.Payload,
|
|
})
|
|
if err != nil { // 编码失败
|
|
slog.Error("room encode event failed", "room_id", r.id, "error", err)
|
|
continue
|
|
}
|
|
if len(e.To) == 0 { // 未指定收件人
|
|
r.hub.Broadcast(r.UserIDs(), data) // 房间内全体
|
|
} else { // 指定收件人
|
|
r.hub.Broadcast(e.To, data) // 定向下发(私有信息)
|
|
}
|
|
}
|
|
}
|
|
|
|
// readyIDs 返回已准备的用户 ID。
|
|
func (r *Room) readyIDs() []int64 {
|
|
ids := make([]int64, 0, len(r.members)) // 预分配
|
|
for uid, m := range r.members { // 遍历成员
|
|
if m.ready { // 已准备
|
|
ids = append(ids, uid) // 收集
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// idleTimeout 返回房间空闲回收时长。
|
|
func idleTimeout() time.Duration {
|
|
if global.WSSetting != nil && global.WSSetting.RoomIdleTimeoutSec > 0 { // 配置了
|
|
return time.Duration(global.WSSetting.RoomIdleTimeoutSec) * time.Second
|
|
}
|
|
return 300 * time.Second // 兜底 5 分钟
|
|
}
|
|
|
|
// reconnectGrace 返回掉线重连宽限时长。
|
|
func reconnectGrace() time.Duration {
|
|
if global.WSSetting != nil && global.WSSetting.ReconnectGraceSec > 0 { // 配置了
|
|
return time.Duration(global.WSSetting.ReconnectGraceSec) * time.Second
|
|
}
|
|
return 30 * time.Second // 兜底 30 秒
|
|
}
|
|
|
|
// newRoomID 生成房间 ID(32 位十六进制,去连字符)。
|
|
func newRoomID() string {
|
|
return strings.ReplaceAll(uuid.NewString(), "-", "") // 去连字符后共 32 字符
|
|
}
|
|
|
|
// 保证 context 包被使用(房间事件循环后续可接入统一 ctx 控制)。
|
|
var _ = context.Background
|