Skip to content

同用户游戏局切换清理 ​

系统文档: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()。

关键反模式:旧局周期保存覆盖新局 ​

要特别检查这种代码:

  1. 旧局每隔 N 秒遍历玩家或缓存的 UserId,把局内内存状态写入 CloudData。
  2. 玩家切换到同游戏另一个游戏局后,新局已经加载并推进状态。
  3. 旧局没有从周期保存集合中移除该用户,稍后继续提交旧快照。
  4. 新局玩家看到的真实进度被旧局较晚写入的旧状态覆盖。

推荐修法是:RegisterCleanup 处理器先提交必要的最后状态,再调用项目自己的 MarkUserHandoffCompleted(context.UserId) / DeactivateUserArchiveWriter(context.UserId) 之类逻辑,让周期保存、延迟结算、dirty 队列和批量写入都跳过该用户。关键资产最好在业务发生时立即事务提交,周期保存只作为缓存收敛。

如果项目需要跨多个游戏局核算用户实际状态,例如项目自定义的集结局、准备局、战斗局、观战局、结算局都可能影响同一用户数据,可以考虑:

这里的“集结局”“准备局”指项目代码自己创建和识别的游戏局形态,不是平台管辖的游戏外大厅。没有明确上下文时,不要对项目自定义游戏局使用带有“大厅”含义的称呼,避免和官方大厅状态混淆。

  • 普通游戏局通过 ServerChannel 上报用户进入、离开、切局、结算批次和最终摘要。
  • 常驻协调局通过 ResidentSession 保持运行,维护每个 UserId 当前可信写入方、最新版本号或待结算批次。
  • CloudData 写入由业务发生点及时提交,或由协调局统一提交;无论哪种方式,都要有幂等 key 和版本检查。
  • UserSessionHandoff 仍负责旧局收到切局通知时的本地停写和最后收口,不负责全项目长期核算。

审查清单 ​

  1. 清理逻辑是否只在服务端注册。
  2. 是否优先使用 context.UserId,而不是 Player.Id 或可空 context.Player。
  3. context.Player == null 时是否仍能完成用户级清理。
  4. CloudData 是否合并事务,并检查 UserCloudDataResult。
  5. 清理是否幂等,重复执行不会重复发奖、重复扣费或重复删除关键资产。
  6. 是否设置了合理的 Name、Order 和 Timeout。
  7. 是否把长任务、跨局协调或全局广播误塞进清理处理器。
  8. 是否误用 CleanupCompleted 执行核心清理。
  9. 如果项目有周期保存、延迟写入或批量保存队列,切局后是否会停止为该 UserId 写旧局快照。
  10. 是否有版本号、批次 ID、当前写入方或其他幂等保护,避免旧局晚到写入覆盖新局状态。
  11. 如果项目已经复杂到多个游戏局共同影响用户云数据,是否应该引入 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。