第 13 章 · 权限与冲突
这一章解决:第 12 章请求式架构在「多人同时请求」下会产生哪些冲突,以及怎么用最小成本的工具(幂等、冷却、Owner 检查、批准回执)处理这些冲突。
先看一下
第 12 章给了 requestId / requestType / requestPayload。两位玩家几乎同时按按钮时,他们各自的 PlayerObject 上会同时出现新的请求字段。GameState Owner 在 OnPlayerRequestUpdated 里依次扫描每位玩家的请求,按读到的顺序处理。
「依次处理」就够了吗?三个具体场景说不够:
- 大厅里只剩 1 个红队名额,A 和 B 同时按「加入红队」。GameState 应该让谁进。
- InGame 中只有一个钥匙,A 和 B 同时拾取。
- 大厅里 Master 按「Start」,但 Master 本人忘了 Ready,按下没反应也没提示。
这一章把这三类冲突逐一处理。版本号(stateVersion)留给闭环 3。
这一章会拿到什么
- 三类冲突场景的最小处理代码
- 「批准回执」模式:GameState 通过
pls.SendCustomNetworkEvent(NetworkEventTarget.Owner, ...)让玩家自己写 PlayerObject 字段 - 一份「冲突类别 → 处理方式」对照表
依赖前面
- 第 12 章请求式架构的字段命名和处理流程
- 第 9 章 PlayerObject 写权边界
- 第 4 章
OnPostSerialization用法
抢加入队伍:批准回执模式
Section titled “抢加入队伍:批准回执模式”第 12 章 HandleJoinTeam 留了四个 TODO。本节填完。
PlayerObject 端先加两个回调(脚本顶部沿用第 12 章的 using 段,特别注意要有 using VRC.SDK3.UdonNetworkCalling;):
public class PlayerLobbyState : UdonSharpBehaviour{ // 第 12 章已有字段省略
// GameState 批准入队后调用这里。Owner 是该玩家本人,所以可以写 teamId [NetworkCallable] public void OnTeamApproved(int approvedTeam) { if (!Networking.IsOwner(gameObject)) return; teamId = (byte)approvedTeam; RequestSerialization(); }
// GameState 拒绝入队时调用这里 [NetworkCallable] public void OnTeamRejected(int reason) { // 0 = 队伍已满, 1 = 队伍 ID 非法, 2 = 比赛已开始 // 本地 UI 弹出提示。这里 Owner 不写网络字段 Debug.Log("[PlayerLobbyState] team rejected, reason=" + reason); }}GameState 端把 HandleJoinTeam 写完整:
[Header("队伍配置")]public int teamCount = 2;public int teamCapacity = 4; // 每队上限
private int[] teamReservationCount = new int[4];
private void ScanPlayerRequests(){ RefreshTeamReservationCount(); // 第 12 章已有:扫描所有玩家并调用 ProcessRequest}
private void HandleJoinTeam(VRCPlayerApi who, byte team){ // TODO 1:合法性 if (team >= teamCount || team >= teamReservationCount.Length) { ReplyTeamRejected(who, 1); return; } // TODO 2:阶段检查 if (phase != PHASE_LOBBY) { ReplyTeamRejected(who, 2); return; } // TODO 3:人数上限。用本轮扫描里的预约人数,而不是只读已经同步回来的 teamId if (teamReservationCount[team] >= teamCapacity) { ReplyTeamRejected(who, 0); return; } // TODO 4:批准。先在 GameState Owner 本地预约名额,再发回执 teamReservationCount[team]++; ReplyTeamApproved(who, team);}
private void RefreshTeamReservationCount(){ for (int i = 0; i < teamReservationCount.Length; i++) teamReservationCount[i] = 0;
int count = VRCPlayerApi.GetPlayerCount(); var players = new VRCPlayerApi[count]; VRCPlayerApi.GetPlayers(players);
for (int i = 0; i < count; i++) { var p = players[i]; if (p == null || !p.IsValid()) continue; var pls = GetPLS(p); // 第 11 章 helper if (pls == null) continue; byte team = pls.TeamId; if (team < teamReservationCount.Length) teamReservationCount[team]++; }}
private void ReplyTeamApproved(VRCPlayerApi who, byte team){ var pls = GetPLS(who); if (pls == null) return;
// 关键一行:把 OnTeamApproved 这条事件发给 who 本人,让他自己写 teamId pls.SendCustomNetworkEvent( VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(PlayerLobbyState.OnTeamApproved), (int)team);}
private void ReplyTeamRejected(VRCPlayerApi who, int reason){ var pls = GetPLS(who); if (pls == null) return; pls.SendCustomNetworkEvent( VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(PlayerLobbyState.OnTeamRejected), reason);}NetworkEventTarget.Owner 在 PlayerObject 上的语义是「这份对象的 Owner」,也就是 who 本人。所以 pls.SendCustomNetworkEvent(target=Owner, method=OnTeamApproved) 等同于「把这条事件发给 who 客户端」。who 客户端收到后,因为他是 PlayerObject 的 Owner,可以合法写 teamId。
整套就是「批准回执模式」:GameState Owner 决定结果,但写 PlayerObject 字段的动作由玩家本人完成。
A 和 B 同时按「加入红队」。两位玩家本地分别 IssueRequest(REQ_JOIN_TEAM, 0),请求字段同步出去。GameState Owner 的 OnPlayerRequestUpdated 扫到两位的请求,按读到的顺序处理(VRChat 同步的 batch 顺序由 SDK 决定,本卷视为不可预测)。
假设红队还剩 1 个名额:
- 本轮扫描开始前,
RefreshTeamReservationCount()读到红队已有 3 人。 - 先处理到 A 的请求,红队预约人数从 3 升到 4,批准 A,发回执,A 端稍后写
teamId=0。 - 处理 B 的请求时,不等 A 的
teamId同步回来,GameState Owner 本地已经知道红队预约人数是 4,于是拒绝 B,发回执原因 0。
哪一位被批准取决于同步顺序。这是可接受的:物理上「同时按下」的概念在分布式系统里没有意义,落到 GameState 端就是先到先批。关键是 GameState Owner 要在同一轮扫描里维护预约人数,不能只依赖 PlayerObject 稍后同步回来的 teamId。
抢按钮:本地预校验 + Owner 防抖
Section titled “抢按钮:本地预校验 + Owner 防抖”InGame 中场景里有一个特殊按钮(限时奖励),第一个按下的玩家拿走。
PlayerObject 端:
public void RequestUseSkill(int skillId, int targetId){ if (!Networking.IsOwner(gameObject)) return;
// 本地预校验:如果本地已经看到「奖励已经被拿走」,直接不发 // 这是省一次往返的优化,不是安全门 if (gameState != null && gameState.BonusTaken) return;
IssueRequest(REQ_USE_SKILL, skillId, targetId);}GameState 端:
[UdonSynced] private bool bonusTaken;[UdonSynced] private int bonusTakenByPlayerId = -1;
public bool BonusTaken => bonusTaken;public int BonusTakenByPlayerId => bonusTakenByPlayerId;
private void HandleUseSkill(VRCPlayerApi who, int skillId, int targetId){ if (skillId == 0 /* BONUS */) { if (bonusTaken) return; // 已被人拿走,忽略后到的请求 bonusTaken = true; bonusTakenByPlayerId = who.playerId; ApplyState(); return; } // 其他技能...}第一个到达 GameState 的请求把 bonusTaken = true,同步出去。后续到达的请求看到 bonusTaken 已为 true,被忽略。
「本地预校验」让玩家在本地已经看到 bonusTaken=true 时不再发请求,省一次同步往返。但它不是安全门:GameState 端的 if (bonusTaken) return 才是真正的判定。本地预校验可能因为同步延迟看到旧值,所以两位玩家都发了请求,最终由 GameState 一锤定音。
防止重复点击:本地冷却
Section titled “防止重复点击:本地冷却”A 在 1 秒内连按按钮 50 次,发出 50 个请求。GameState 全部受理,分数加 50。这通常不是预期。
PlayerObject 端加冷却:
private float lastIssueTime = -999f;public float requestCooldown = 0.3f;
private void IssueRequest(byte type, int payload, int extra){ if (!Networking.IsOwner(gameObject)) return;
// 本地冷却:同一个 PlayerObject 0.3 秒内只发一次 if (Time.time - lastIssueTime < requestCooldown) return; lastIssueTime = Time.time;
requestId++; requestType = type; requestPayload = payload; requestPayloadExtra = extra; RequestSerialization(); if (gameState != null) { gameState.SendCustomNetworkEvent( VRC.Udon.Common.Interfaces.NetworkEventTarget.Owner, nameof(GameState.OnPlayerRequestUpdated)); }}本地冷却拦得住「正常玩家手抖」,拦不住「故意改客户端代码绕过冷却」。后者要靠 GameState 端冷却:
private float[] lastProcessTime = new float[100]; // 按受控 slot 索引
private void ProcessRequest(VRCPlayerApi player, PlayerLobbyState pls){ // GameState 端冷却:同一玩家 0.3 秒内只处理一次 if (player.playerId < lastProcessTime.Length) { if (Time.time - lastProcessTime[player.playerId] < 0.3f) return; lastProcessTime[player.playerId] = Time.time; }
byte type = pls.RequestType; // ...原有逻辑}注意 lastProcessTime 是 GameState Owner 客户端本地字段(不加 [UdonSynced])。Owner 转移瞬间这些数据会丢失,新 Owner 一开始没有冷却历史。本卷视为可接受:Owner 转移本身就少见,转移后的几秒里不严格冷却不会破坏体验。
冷却写在哪一层取决于威胁模型:
| 冷却位置 | 拦截谁 | 代价 |
|---|---|---|
| PlayerObject 本地 | 正常玩家手抖、按钮连按 | 改客户端可绕过 |
| GameState 端 | 改客户端的玩家也拦得住 | Owner 转移时冷却历史丢失 |
| 两者都做 | 全部 | 最稳,代码翻倍 |
合作 PvE 用 PlayerObject 本地冷却就够了。竞技对抗两层都要做。
冲突类别对照表
Section titled “冲突类别对照表”把本章三类场景汇总:
| 冲突类型 | 例子 | 处理 | 留给闭环 3 的部分 |
|---|---|---|---|
| 资源争夺 | 加入队伍、拾取唯一物品 | GameState 端先到先得 + 批准回执 | 无 |
| 重复请求 | 按 Score 50 次 | 本地冷却 + GameState 端冷却 | 总分上限 / 每秒上限 |
| 状态时序 | 网络抖动下「请求看到的 phase」与 GameState 实际 phase 不一致 | GameState 端 phase 校验(已有 if (phase != PHASE_INGAME) return) | stateVersion 字段,让客户端能识别自己看到的状态版本 |
第三行是闭环 2 故意保留、闭环 3 才修的缺陷。本章只处理前两类。
- 把队伍切换写成「玩家可以反复切换 Team,不需要 GameState 批准」。在什么场景下这样写是合理的?什么场景不合理?
- 给 RequestScore 加一个全局上限:每秒整局最多 +5 分。这一项写在 PlayerObject 本地、GameState 端、两个地方都写,三种实现各自的代码量与可绕过性怎么对比?
- 实现一个「主持人接管 GameState Owner」的按钮:主持人客户端按下后,GameState 的 Owner 转给他。怎样防止有人冒充主持人?
| 场景 | 预期 | 实际 |
|---|---|---|
| 1. A、B 同时抢红队最后一个名额 | 一位被批准,另一位收到 OnTeamRejected reason=0 | … |
| 2. A 1 秒内连按 Score 10 次 | 本地冷却下只发 3–4 次(0.3s 间隔),分数实际 +3 或 +4 | … |
| 3. A 改客户端绕过本地冷却,连按 50 次 | GameState 端冷却仍然拦下,分数 +N(N 远小于 50) | … |
| 4. A 拾取唯一奖励,B 0.1 秒后也按拾取 | A 拿到奖励,B 收到「已被拿走」反馈或本地预校验直接不发 | … |
| 5. GameState Owner 转移后,本地 lastProcessTime 丢失 | 转移后短期内冷却不严格,不影响正确性 | … |
- 能复述「批准回执」模式三个步骤
- 能解释为什么 PlayerObject 本地冷却拦不住改客户端的玩家
- 能写出
ReplyTeamApproved里SendCustomNetworkEvent的 target 与 method 各是什么 - 能识别本章哪些冲突处理只到「最小可用」,哪些将由闭环 3 的版本号补完
- VRChat Creator Docs · Network Events:
SendCustomNetworkEvent的 target、参数、速率限制。 - VRChat Creator Docs · Object Ownership:Owner 写权、
SetOwner、OnOwnershipTransferred的官方说明。 - 第 12 章 · 请求式架构:本章扩展的母本。
- 闭环 1 · 缺陷 2 现场:
lobbyReady缺陷已经间接展示了写权冲突。 - 附录 · 术语表:
Conflict Resolution、Approval Reply、Cooldown的客观定义。