主题
同用户游戏局切换清理
系统文档:docs/systems/UserSessionHandoffSystem.md。
何时使用
任务涉及以下内容时使用本 Skill:
- 同一用户进入同一游戏的另一个游戏局前,旧游戏局需要提交云数据。
- 排查两局同时写同一用户云数据、后写覆盖先写、离线快照丢失等问题。
- 排查旧局周期性保存所有玩家云数据,玩家已进入同游戏其他局后仍被旧局旧快照覆盖的问题。
- 旧游戏局收到用户级清理请求时,当前玩家槽位可能已经不存在。
- 设计短时间、幂等的用户级资源释放或状态收尾逻辑。
- 审查
UserSessionHandoff.RegisterCleanup、UserSessionHandoffContext、UserSessionHandoffOptions、CleanupCompleted用法。
硬规则
- API 仅在
#if SERVER中使用。 - 使用
UserSessionHandoff.RegisterCleanup(...)注册清理处理器。 - 云数据和持久用户状态必须使用
context.UserId。不要使用Player.Id。 context.Player可能为null;不要因为找不到 Player 就跳过用户级清理。- 清理处理器必须短小、可重复执行,优先写“最新状态快照”而不是追加不可重复的奖励或扣费操作。
- 如果旧局有周期保存、延迟写入或批量提交队列,清理处理器必须让后续任务跳过当前
context.UserId;不要只“最后保存一次”后继续让旧局代表该用户写 CloudData。 - 周期保存所有玩家云数据时,不要只按旧局玩家列表或缓存列表无条件写入;要维护按
UserId的 active/dirty 状态,并能在同用户切局时停用该用户。 - 不要把本 API 当作匹配、排队、邀请、进房裁决或跨局发现机制。
- 清理窗口发生在用户正式完成新游戏局连入之前,新局玩家连入事件不会先于旧局清理完成触发;但清理完成或等待超时后平台仍会继续后续连入流程,不要设计成长期门禁。
- 不要在处理器中执行长时间后台任务;需要长期协调时考虑常驻游戏局、ServerChannel 和 CloudData 持久状态。
- 复杂项目可以用
ServerChannel上报各游戏局的用户状态变化,用ResidentSession常驻协调局汇总“哪个游戏局仍可代表该用户写云数据”;不要把全项目核算都塞进单次 handoff handler。 - 处理 CloudData 结果码,避免在
FailedToSend、TooManyWriteOperations等情况下无限重试。 - 如果注册多个处理器,用
UserSessionHandoffOptions.Order明确顺序,用Timeout控制单处理器耗时。Order默认值为0;Timeout默认值为null,表示不设置单处理器独立超时。 UserSessionHandoff.DefaultRequestTimeout默认值为 3500 毫秒,是整次清理请求的总时间预算,也是当前允许设置的最大值;项目代码只能把该值调小,不能调大。DefaultRequestTimeout只表示 WasiCore 旧游戏局清理预算,不等同于平台外层切换流程的全部等待上限;不要尝试通过它延长平台等待,或实现排队、审核、阻止进房。- 达到总时间预算后,旧局应视为本次清理窗口结束;不要假设可以通过继续等待来阻止用户进入新局。
CleanupCompleted只用于日志、指标和诊断,不要在这里继续做核心清理。
推荐写法
csharp
#if SERVER
using GameCore.GameSystem;
using GameCore.UserCloudData;
private IDisposable? _handoffCleanup;
private void RegisterHandoffCleanup()
{
_handoffCleanup = UserSessionHandoff.RegisterCleanup(
SaveArchiveAsync,
new UserSessionHandoffOptions
{
Name = "SaveArchive",
Order = 0,
Timeout = TimeSpan.FromSeconds(2),
});
}
private async Task SaveArchiveAsync(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如果模块可能重复初始化,保留注册句柄并在卸载时调用 _handoffCleanup?.Dispose()。
关键反模式:旧局周期保存覆盖新局
要特别检查这种代码:
- 旧局每隔 N 秒遍历玩家或缓存的
UserId,把局内内存状态写入 CloudData。 - 玩家切换到同游戏另一个游戏局后,新局已经加载并推进状态。
- 旧局没有从周期保存集合中移除该用户,稍后继续提交旧快照。
- 新局玩家看到的真实进度被旧局较晚写入的旧状态覆盖。
推荐修法是:RegisterCleanup 处理器先提交必要的最后状态,再调用项目自己的 MarkUserHandoffCompleted(context.UserId) / DeactivateUserArchiveWriter(context.UserId) 之类逻辑,让周期保存、延迟结算、dirty 队列和批量写入都跳过该用户。关键资产最好在业务发生时立即事务提交,周期保存只作为缓存收敛。
如果项目需要跨多个游戏局核算用户实际状态,例如项目自定义的集结局、准备局、战斗局、观战局、结算局都可能影响同一用户数据,可以考虑:
这里的“集结局”“准备局”指项目代码自己创建和识别的游戏局形态,不是平台管辖的游戏外大厅。没有明确上下文时,不要对项目自定义游戏局使用带有“大厅”含义的称呼,避免和官方大厅状态混淆。
- 普通游戏局通过
ServerChannel上报用户进入、离开、切局、结算批次和最终摘要。 - 常驻协调局通过
ResidentSession保持运行,维护每个UserId当前可信写入方、最新版本号或待结算批次。 - CloudData 写入由业务发生点及时提交,或由协调局统一提交;无论哪种方式,都要有幂等 key 和版本检查。
UserSessionHandoff仍负责旧局收到切局通知时的本地停写和最后收口,不负责全项目长期核算。
审查清单
- 清理逻辑是否只在服务端注册。
- 是否优先使用
context.UserId,而不是Player.Id或可空context.Player。 context.Player == null时是否仍能完成用户级清理。- CloudData 是否合并事务,并检查
UserCloudDataResult。 - 清理是否幂等,重复执行不会重复发奖、重复扣费或重复删除关键资产。
- 是否设置了合理的
Name、Order和Timeout。 - 是否把长任务、跨局协调或全局广播误塞进清理处理器。
- 是否误用
CleanupCompleted执行核心清理。 - 如果项目有周期保存、延迟写入或批量保存队列,切局后是否会停止为该
UserId写旧局快照。 - 是否有版本号、批次 ID、当前写入方或其他幂等保护,避免旧局晚到写入覆盖新局状态。
- 如果项目已经复杂到多个游戏局共同影响用户云数据,是否应该引入
ServerChannel+ResidentSession协调局,而不是继续扩大单局周期保存逻辑。
和其他系统的边界
- 玩家正常离开本局:优先用玩家退出相关事件或普通玩法流程保存状态。
- 当前游戏局结束:使用
Game.OnGameEnd做全局资源清理。 - 同用户切换到同游戏另一个局前的用户级收尾:使用
UserSessionHandoff。 - 跨游戏局主动通信:使用
ServerChannel。 - 玩家主动分享并加入开放局:联用
drop-in-open-game,并阅读OpenGameJoinSystem。 - 玩家全部离线后仍要长期运行:阅读
ResidentSessionSystem。
相关 Skill
- 云数据读写、ready gate、限额和错误处理:
cloud-data。 - 跨游戏局消息协调:阅读
docs/systems/ServerChannelSystem.md。 - 常驻协调局:
resident-session。 - 随进随出配置和开放局加入链路:联用
drop-in-open-game,并阅读docs/systems/DropInOpenGameSystem.md与docs/systems/OpenGameJoinSystem.md。