团本创建、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 依次检查:

  1. 玩家必须有 GameGuildData.Id。
  2. 最多等待 3 秒读取公会 Redis 数据。
  3. Redis 中不能已经存在 BattleId。
  4. 当前时间必须命中某个 StartTimestamp <= now < EndTimestamp 的轮次。
  5. 当前轮次 Status != 0。
  6. 玩家当天的 BossFoughtInfo 不能已经包含本轮 Id。
  7. 必须拿到公会级创建锁。

创建锁:

string lockKey =
    $"/qmzs/lobby/createbattlepveboss_{GameGuildData.Id}";

database.LockTake(
    lockKey,
    GameGuildData.Id,
    TimeSpan.FromSeconds(20));

锁的粒度是公会,不是玩家,也不是 Lobby 节点,所以不同 Lobby 上的同公会成员仍会竞争同一把 Redis 锁。

4. 本日 Boss 轮次从哪里来

InitPveBossInfo 每日初始化公会轮次:

  1. 用 /qmzs/lobby/initpvebossinfo_{guildId} 做 15 秒分布式锁。
  2. 从 TGameplayGHBoss.Time 按“开始、结束”两两读取时间段。
  3. 从 TGameplayGHBossSelect 按 DifficultyWeighting 抽难度。
  4. 再按 BossWeighting 抽 Boss,并尽量避免同一天重复。
  5. 给每轮生成 root.NewEntityId 作为轮次唯一 Id。
  6. 写入公会 Redis 的 roundInfo 与 roundUpdateDate。
  7. 通过 ServerPacket_Transfer 向在线公会成员发送 ScGameGuildRoundInfo。

轮次协议字段:

字段用途
BossId/BossName本轮替换后的 Boss
Difficulty实际用于加载团本地图
StartTimestamp/EndTimestamp入场时间窗
MaxBossHp/CurBossHp跨服务摘要
MaxPlayerCount/CurPlayerCount面板摘要
Id本轮幂等键
Status0 表示不可再进入

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 校验:

  1. Battle 状态必须是 Battle。
  2. 若已经有同 playerId,不重复创建玩家。
  3. CanAddPlayer() 必须通过。
  4. 新玩家加入 ComPlayerMgr。
  5. 回传 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 先释放锁,再异步批量写入口。这可能导致极端并发下重复创建。修复方向见 并发锁、故障边界与排错。

上一篇:栏目首页 · 下一篇:首帧快照、AOI 与实时增量