团本创建、BattleId 路由与中途加入 private
团本创建、BattleId 路由与中途加入
本篇回答最核心的问题:两个不同时刻点击“进入”的公会成员,为什么最后会连到同一个 Boss 战,而不是分别创建两场。
1. 客户端永远先发“创建”
团本面板没有让客户端自己决定应该创建还是加入。点击按钮时,无论 MyGuildInfo.BattleId 当前是什么,最终都向 Lobby 发送 CsCreateBattle:
NetworkManager.Instance.SendNenwordMessage(
(ushort)emPacket_Battle.CsCreateBattle,
new Packet_Battle_CS_CreateBattle
{
ChapterId = 1,
WorldId = 1,
LevelId = 0,
BattleType = emBattleType.GameGuildPveBoss,
Hero = GameTools.ConvertToEmHero(
WorldAdapter.UniqueWorld.GetCurrHero().heroCategory),
Subrole = (int)WorldAdapter.UniqueWorld.GetCurrHero().SubRole,
});
这样设计的关键是:创建还是复用必须由 Lobby 根据公会共享状态决定,不能相信客户端缓存的 BattleId。
协议定义:
message Packet_Battle_CS_CreateBattle {
emBattleType battleType = 1;
int32 ChapterId = 2;
int32 WorldId = 3;
int32 LevelId = 4;
emHero hero = 5;
int32 subrole = 6;
int32 baguaStyle = 7;
bool spiritBurning = 8;
}
2. Lobby 的创建/加入二选一
SystemBattle.OnEvent_CsCreateBattle 遇到团本后调用:
if (!player.comGameGuild.BeginCreateBattlePveBoss())
{
string pveBossBattleId =
player.comGameGuild.GetPveBossBattleId();
if (pveBossBattleId.IsEmpty())
{
// 返回创建失败
return;
}
// 伪造 EnterBattle 请求,复用已有 Battle
OnEvent_CsEnterBattle(player, fakePacket);
return;
}
因此 BeginCreateBattlePveBoss() 的返回值不是简单的“允许进入”:
| 返回值 | 含义 | 后续动作 |
|---|---|---|
true | 当前无共享 BattleId,并成功取得创建锁 | 创建新的 Battle Actor |
false 且 Redis 有 BattleId | 已有团本或创建竞争失败 | 转成 EnterBattle |
false 且 Redis 无 BattleId | 无有效团本,或正处于创建竞态/校验失败 | 返回错误 |
3. 创建前的业务校验
BeginCreateBattlePveBoss 依次检查:
- 玩家必须有
GameGuildData.Id。 - 最多等待 3 秒读取公会 Redis 数据。
- Redis 中不能已经存在
BattleId。 - 当前时间必须命中某个
StartTimestamp <= now < EndTimestamp的轮次。 - 当前轮次
Status != 0。 - 玩家当天的
BossFoughtInfo不能已经包含本轮Id。 - 必须拿到公会级创建锁。
创建锁:
string lockKey =
$"/qmzs/lobby/createbattlepveboss_{GameGuildData.Id}";
database.LockTake(
lockKey,
GameGuildData.Id,
TimeSpan.FromSeconds(20));
锁的粒度是公会,不是玩家,也不是 Lobby 节点,所以不同 Lobby 上的同公会成员仍会竞争同一把 Redis 锁。
4. 本日 Boss 轮次从哪里来
InitPveBossInfo 每日初始化公会轮次:
- 用
/qmzs/lobby/initpvebossinfo_{guildId}做 15 秒分布式锁。 - 从
TGameplayGHBoss.Time按“开始、结束”两两读取时间段。 - 从
TGameplayGHBossSelect按DifficultyWeighting抽难度。 - 再按
BossWeighting抽 Boss,并尽量避免同一天重复。 - 给每轮生成
root.NewEntityId作为轮次唯一Id。 - 写入公会 Redis 的
roundInfo与roundUpdateDate。 - 通过
ServerPacket_Transfer向在线公会成员发送ScGameGuildRoundInfo。
轮次协议字段:
| 字段 | 用途 |
|---|---|
BossId/BossName | 本轮替换后的 Boss |
Difficulty | 实际用于加载团本地图 |
StartTimestamp/EndTimestamp | 入场时间窗 |
MaxBossHp/CurBossHp | 跨服务摘要 |
MaxPlayerCount/CurPlayerCount | 面板摘要 |
Id | 本轮幂等键 |
Status | 0 表示不可再进入 |
5. 创建载荷怎样到战斗服
LobbyStatusCreateBattle.Begin 组装 ServerPacket_CreateBattleData,其中包括完整的首位玩家数据和本轮 Boss:
message ServerPacket_CreateBattleData {
emCreateBattleType createType = 1;
emBattleType battleType = 2;
int32 ChapterId = 3;
int32 WorldId = 4;
int32 LevelId = 5;
repeated t_EnterBattlePlayer players = 6;
int32 BattleOwerId = 7;
t_GameGuildPveBossInfo ReplacedBossInfo = 8;
}
团本的 ReplacedBossInfo 是从 Redis 的当前时间段中再次选择的。随后 Lobby 调用 ClientBattleMgr.CreateBattle,由 BattleMgr 选择一个 Battle 节点并创建战斗。
Battle 节点的工厂分派:
case emBattleType.GameGuildPveBoss:
return BattleGuildPveBoss.Props(
BattleId, mRoot, battleType);
Actor 创建后,BattleGuildPveBoss.OnCreateRequest:
- 从首批玩家属性取得
battleGameGuildId。 - 把玩家加入
ComPlayerMgr。 - 保存
CurRound = BattleData.ReplacedBossInfo。 - 使用
CurRound.Difficulty加载地图。 - 场景实体初始化时,把地图里的 Boss 配置替换成
CurRound.BossId。 - 人数上限读取
TGameplayGHBoss.GetConfig(1).MaxPlayerCount,缺配置才回落到通用 PVE 上限。
注意:客户端请求里的 WorldId = 1 不是最终选图依据,真正选图使用的是服务端轮次难度。
6. BattleId 何时成为公会入口
创建成功回到 Lobby 后,EndCreateBattlePveBoss:
await database.LockReleaseAsync(lockKey, lockVal);
batch.HashSetAsync(guildKey, "battleId", battleId);
batch.HashSetAsync(
guildKey,
"curRoundInfo",
JsonConvert.SerializeObject(curRoundInfo));
batch.Execute();
此后所有成员查询同一个公会 Redis,就会得到同一个 battleId。这就是团本复用的路由锚点。
sequenceDiagram
participant A as 成员 A
participant LA as Lobby A
participant R as Redis
participant M as BattleMgr
participant B as Battle Actor
A->>LA: CsCreateBattle(GameGuildPveBoss)
LA->>R: 读 roundInfo / battleId
LA->>R: 获取 guildId 创建锁
LA->>M: CreateBattle(createData)
M->>B: 创建 BattleGuildPveBoss
B-->>LA: battleId + ReplacedBossInfo
LA->>R: 写 battleId / curRoundInfo
LA-->>A: ScCreateBattleRst
7. 第二个成员怎样中途加入
第二个成员仍发送 CsCreateBattle。Lobby 发现不能创建后读取共享 battleId,构造内部 CsEnterBattle。
EnterBattlePveBoss(battleId) 再做一次校验:
- 玩家有公会。
- 请求 BattleId 非空。
- Redis 当前 BattleId 必须与请求完全相等。
- 必须存在当前轮次。
- 玩家不能已记录参加过本轮。
通过后,LobbyStatusEnterBattle.Begin 组装新的 t_EnterBattlePlayer,携带该玩家自己的角色、装备、神魂、符文、任务等数据,然后:
player.ClientBattle.SendToBattle(
BattleId,
emServerBattleMsg.EnterBattle,
req);
战斗服 HandlePacketStateSync.OnEnterBattle 校验:
- Battle 状态必须是
Battle。 - 若已经有同
playerId,不重复创建玩家。 CanAddPlayer()必须通过。- 新玩家加入
ComPlayerMgr。 - 回传 BattleId、Battle 地址、玩法类型、当前人数和最大人数。
如果 Battle 已结束返回 BattleSettlement;不存在返回 NotFindBattle;满员返回 BattlePlayerIsMax。
当 Lobby 收到 NotFindBattle 时,会清空 Redis 中陈旧的 battleId 和 curRoundInfo,下一次点击才能重新走创建逻辑。
8. 谁负责公会隔离
正常信任边界是:
客户端 -> Lobby 鉴权与公会校验
-> Lobby 构造可信 t_EnterBattlePlayer
-> 内部消息进入 Battle
Battle 的 OnEnterBattle 本身主要检查状态、重复玩家和人数上限,没有再次比较新玩家的 GameGuildId 与 battleGameGuildId。因此“同公会”约束实际建立在 Lobby 入口和内部网络可信边界上。若未来开放新的内部调用方,Battle 端应该再补一次公会 ID 校验。
9. 创建/加入时序中的关键竞态
当前实现存在一个小窗口:
释放创建锁
↓
尚未写入 battleId
↓
另一成员取得锁并读到空 battleId
因为 EndCreateBattlePveBoss 先释放锁,再异步批量写入口。这可能导致极端并发下重复创建。修复方向见 并发锁、故障边界与排错。