主题
同用户游戏局切换清理
概述
同一平台用户进入同一游戏的另一个游戏局时,旧游戏局可能还来得及提交最后一小段用户级清理逻辑。UserSessionHandoff 提供的就是这次旧局清理机会,主要用于云数据提交、临时锁释放、离线快照落盘或跨局状态收尾。
这个能力只在服务端可用。它不是匹配、排队、邀请或进房裁决系统;项目代码不需要也不能通过它返回“同意 / 拒绝”。清理处理器应保持短小、幂等,并以 UserId 作为持久用户数据的主键。
在这段清理窗口内,用户尚未正式完成新游戏局连入,新游戏局的玩家连入事件也不会先于旧局清理完成触发。清理完成或等待超时后,平台会继续后续连入流程;因此项目不能把这个机制设计成长期阻止用户进入新局的业务门禁。
关键风险:旧局周期保存覆盖新局状态
最需要防范的不是“切局时没保存一次”,而是旧游戏局在玩家离开后仍继续把旧状态写回同一份用户云数据。典型反模式是:服务端每隔一段时间遍历本局所有玩家,把内存中的等级、背包、任务进度或局内结算状态保存到 CloudData;用户切换到同游戏另一个游戏局后,新局已经加载并推进了玩家状态,但旧局的周期保存任务仍把旧快照提交上去,导致玩家在新局实际看到的进度被旧局较晚写入的数据覆盖。
一个常见时间线:
- 玩家在 A 局中获得道具,A 局内存状态为
inventory = old_state,并按 30 秒周期保存。 - 玩家切换进入 B 局,B 局读取 CloudData 并继续游戏,玩家又获得了新的道具或进度。
- A 局没有停止该玩家的周期保存,稍后仍把 A 局旧内存快照写入 CloudData。
- 玩家下次加载或 B 局后续刷新时,看到的数据可能回退到 A 局的旧状态。
UserSessionHandoff 的推荐用途是:在收到同用户切局清理请求时,为该 UserId 做最后一次必要保存,然后把该用户从旧局后续周期保存、延迟结算、脏数据队列或临时资源表中移除。也就是说,清理处理器不只是“多保存一次”,更重要的是让旧局停止继续代表这个用户写入持久云数据。
推荐策略:
- 周期保存不要只遍历
Player.AllPlayers或本局缓存列表后无条件写 CloudData;应维护按UserId的 active/dirty 状态,并能在切局清理时停用该用户。 - 清理处理器中先提交必要的最后快照,再标记该
UserId已交接,后续周期任务遇到该用户直接跳过。 - 如果存档有版本号、递增序号或当前游戏局标识,提交前可以检查本局是否仍是该用户的最新写入方,避免旧局晚到写入覆盖新局。
- 对背包、货币、任务进度等关键资产,优先在业务发生时以事务提交;周期保存适合作为缓存收敛,不应成为唯一权威写入点。
- 追加型记录要有幂等 key,例如结算批次 ID、交易 ID 或奖励 ID,避免切局清理和周期保存重复执行造成重复发奖。
如果项目本身允许一个用户在多个游戏局之间频繁切换,或存在项目自定义的集结局、准备局、观战局、匹配局、战斗局、结算局等多类服务端实例,可以进一步引入跨局核算结构:普通游戏局通过 跨服务器频道系统 上报用户进入、离开、切局和结算摘要;一个由项目识别的 常驻游戏局 负责汇总这些消息、维护“当前哪个游戏局仍可代表该用户写入云数据”的状态,并把最终结果提交到 CloudData。这样可以把用户状态核算从单个旧局的内存周期任务中抽出来,降低多个游戏局同时写同一份用户云数据的风险。
这里的集结、准备、观战、结算等状态都指项目在游戏局内自行设计的玩法状态或服务端实例形态,不是平台管辖的游戏外大厅。没有明确上下文时,不建议对项目自定义游戏局使用带有“大厅”含义的称呼,避免和官方大厅状态混淆。
这种结构不是所有项目都需要。普通对局只要做到“业务发生时及时提交、切局时停用旧局后续写入”通常已经足够;只有当用户状态天然跨多个游戏局流转、需要统一结算或需要跨局确认最新写入方时,才建议引入 ServerChannel 与常驻协调局。
适用场景
- 玩家在同一游戏内切换到另一个游戏局前,旧局需要把本局内存状态提交到 CloudData。
- 旧局保存了按用户维度占用的临时资源,例如交易状态、排队标记、短期缓存或离线快照。
- 同一用户可能在短时间内重复切局,需要把收尾逻辑做成可重复执行、不会重复发奖或重复扣除的流程。
- 旧局存在按周期保存所有玩家云数据的任务,需要在玩家切到其他游戏局后停止继续写该用户的旧局快照。
不适合的场景:
- 决定玩家是否能加入某个游戏局。
- 长时间计算、跨局调度、排行榜重算或后台任务。
- 替代普通玩家退出事件、
Game.OnGameEnd、常驻游戏局或 ServerChannel。
基本用法
在服务端初始化阶段注册清理处理器:
csharp
#if SERVER
using GameCore.GameSystem;
using GameCore.UserCloudData;
private IDisposable? _handoffCleanupRegistration;
private void RegisterUserCleanup()
{
_handoffCleanupRegistration = UserSessionHandoff.RegisterCleanup(
SaveUserArchiveBeforeHandoffAsync,
new UserSessionHandoffOptions
{
Name = "SaveUserArchive",
Order = 0,
Timeout = TimeSpan.FromSeconds(2),
});
}
private async Task SaveUserArchiveBeforeHandoffAsync(UserSessionHandoffContext context)
{
var snapshot = BuildArchiveSnapshot(context.UserId);
var result = await CloudData.ForUser(context.UserId)
.SetData("archive_snapshot", snapshot)
.SetData("archive_saved_at", DateTime.UtcNow.ToString("O"))
.WithDescription("同用户切换游戏局前保存存档")
.ExecuteAsync()
.WaitAsync(context.CancellationToken);
if (result != UserCloudDataResult.Success)
{
Game.Logger.LogWarning("切局前保存云数据失败: {Result}", result);
}
}
#endif如果项目模块会被卸载或重复初始化,保留 IDisposable 并在不再需要时调用 Dispose(),避免重复注册同一段清理逻辑。
上下文对象
UserSessionHandoffContext 提供:
| 属性 | 说明 |
|---|---|
UserId | 平台用户的全局唯一标识。云数据、离线快照和跨局用户状态应优先使用这个值。 |
User | 当前用户对象;如果旧局已找不到玩家,框架会基于 UserId 创建轻量用户对象。 |
Player | 旧游戏局中仍存在的玩家对象;可能为 null。 |
HasLocalPlayer | 是否还能在旧局中找到对应玩家。 |
Reason | 触发原因,目前为 JoiningAnotherSessionOfSameGame。 |
TimeBudget | 本次清理请求的总时间预算。 |
CancellationToken | 超过总预算时会取消。异步云数据或自定义等待应传入这个 token,或使用 WaitAsync(context.CancellationToken)。 |
Items | 同一次清理请求内多个处理器共享的临时字典,不会持久化。 |
重点是:Player 可空。不要把“找不到玩家对象”当作无需清理的信号;用户级云数据清理应继续使用 UserId 执行。
顺序、超时和诊断
多个处理器会按 UserSessionHandoffOptions.Order 从小到大执行;Order 默认值为 0,顺序相同时按注册顺序执行。单个处理器可以设置 Timeout;Timeout 默认值为 null,表示不设置单处理器独立超时。整次请求还会受到 UserSessionHandoff.DefaultRequestTimeout 限制,该总预算默认值为 3500 毫秒,也是当前允许设置的最大值。项目代码可以把它调小,让旧局更快结束清理等待;达到总预算后,旧局会结束本次清理等待,平台继续后续连入流程;这能避免旧局异常或清理过慢导致用户一直卡在切换过程中。
DefaultRequestTimeout 只表示 WasiCore 旧游戏局清理处理器的总预算,不等同于平台外层切换流程的全部等待上限。平台外层还有独立保护超时;因此不能把该值设得比默认值更长,也不要依赖它实现排队、审核或阻止进房。
框架会捕获单个处理器异常并继续后续处理器。需要观察整体结果时,可以订阅 CleanupCompleted:
csharp
#if SERVER
UserSessionHandoff.CleanupCompleted += report =>
{
Game.Logger.LogInformation(
"用户 {UserId} 切局清理完成,耗时 {Duration},超时={TimedOut}",
report.UserId,
report.Duration,
report.TimedOut);
};
#endifCleanupCompleted 适合写日志和指标,不适合再发起耗时清理。真正需要保证顺序的清理工作应注册为 RegisterCleanup 处理器。
与 CloudData 的关系
切局清理最常见的用途是保存用户云数据。设计时遵循云数据系统的基本规则:
- 使用
context.UserId,不要使用Player.Id。 - 把相关字段合并到一次事务,避免在短时间内多次提交。
- 如果旧局有周期保存任务,切局清理完成后必须让该任务跳过当前
UserId,避免旧局后续保存覆盖新局状态。 - 处理
UserCloudDataResult,不要把业务失败当作网络失败无限重试。 - 清理逻辑应幂等。例如保存快照、更新时间戳、写入最新状态通常比“追加一条奖励记录”更安全。
- 如果云数据服务尚未 ready,清理处理器不应长时间等待;可以使用较短 timeout 或跳过非关键数据。
如果某项状态对玩家资产非常关键,仍应在正常玩法流程中及时提交,不能只依赖切局清理。切局清理是补救窗口,不是唯一存档点。
事件范围
该清理请求只会发给平台识别到的旧游戏局。项目不能把它当作“所有游戏局都会收到”的广播事件,也不能用它来发现其他游戏局。跨游戏局主动通信仍应使用 跨服务器频道系统;后台随进随出配置见 随进随出模式与开放局,玩家主动加入开放局的客户端流程见 开放局加入系统。
常见问题
为什么 Player 是 null?
用户切换过程中,旧局可能已经清理了玩家槽位,或者本次请求只携带用户级信息。此时仍可以使用 context.UserId 保存云数据或释放用户级资源。
可以在处理器里等待很久吗?
不建议。处理器应只做短时间、必要的收尾。长任务应拆到正常玩法流程、常驻游戏局或项目自己的后台协调逻辑中。
清理失败会怎样?
框架会记录处理器异常或超时,并继续后续处理器。项目应把关键状态设计成平时就及时保存,切局清理只作为尽力而为的额外收口。
清理期间新局已经连入了吗?
没有。该清理窗口发生在用户正式完成新游戏局连入之前,新局玩家连入事件不会先于旧局清理完成触发。但这个等待有时间上限;旧局清理完成或等待超时后,平台仍会继续让用户进入新局。项目应利用这段时间尽快保存和停写旧局用户状态,而不是把它当作可无限等待的进房前置任务。
旧局还能周期保存其他玩家吗?
可以。切局清理只针对本次请求中的 UserId。旧局如果仍有其他玩家或常规逻辑,可以继续保存其他用户的数据;但应确保已经切走的用户不再出现在旧局的周期保存集合、延迟写入队列或批量提交列表中。