实现玩家数据和购买系统

*此内容使用人工智能(Beta)翻译,可能包含错误。若要查看英文页面,请点按 此处

背景

Roblox 提供一组 API,用于通过 DataStoreService 与数据存储进行接口。这些 API 的最常见的使用案例是保存、加载和复制 玩家数据 。即与玩家进度、购买和其他会话特性相关的数据,在个人游戏会话之间持续存在。

Roblox 上的大多数体验使用这些 API 来实现某种形式的玩家数据系统。这些实现方式在方法上各不相同,但一般都试图解决相同的问题集。

常见问题

以下是玩家数据系统尝试解决的一些最常见问题:

  • 在内存访问中: DataStoreService 请求使用异步操作的网络请求,并受到速率限制。这适合在游戏开始时的初始加载,但不适合在游戏正常进行期间的高频读写操作。大多数开发者的玩家数据系统在 Roblox 服务器上存储此数据在内存中,限制 DataStoreService 请求转化为以下场景:

    • 在会话开始时的初始阅读
    • 会话结束时的最后写入
    • 定期在特定间隔写入以减少最终写入失败的情况
    • 写入以确保在处理购买时保存数据
  • 高效存储: 将玩家的所有会话数据存储在单个表中,可以让您在更少的请求中更新多个值并处理相同数量的数据。它还消除了间值同步化风险,使撤销更容易理解。

    一些开发者还实现了自定义序列化来压缩大数据结构(通常用于保存游戏用户生成的内容)。

  • 复制: 客户端需要定期访问玩家的数据(例如,更新用户界面)。将玩家数据复制到客户端的通用方法可让您无需为每个数据组件创建特定的复制系统即可传输此信息。开发人员经常希望选项对客户端复制的内容进行选择。

  • 错误处理: 当数据存储无法访问时,大多数解决方案都会实现重试机制和“默认”数据的备份。为了确保备份数据不会在之后覆盖“真实”数据,并且这一点适当地向玩家通知,需要特别关注保证备份数据不会覆盖“真实”数据。

  • 重试: 当数据存储不可用时,大多数解决方案实现了重试机制和向默认数据的返回。特别注意确保备份数据不会在之后覆盖“真实”数据,并及时向玩家传达情况。

  • 会话锁定: 如果单个玩家的数据被加载并保存在多个服务器上,可能出现一个服务器保存过期信息的问题。这可能会导致数据丢失和常见物品复制漏洞。

  • 原子购买处理: 验证、奖励和记录购买原子以防止物品多次丢失或获得奖励。

示例代验证码

Roblox 有参考代码可以帮助您设计和构建玩家数据系统。本页的剩余部分检查了背景、实现细节和一般警告。


导入模型到 Studio 后,您应该看到以下文件夹结构:

Explorer window showing the purchasing system model.

架构

这个高级图解释了样本中的关键系统以及它们如何与体验中的其他代码进行接口。

An architecture diagram for the code sample.

重试

类: DataStoreWrapper >

背景

由于 DataStoreService 在内部进行网络请求,其请求不得保证成功。当这发生时, DataStore 方法会抛出错误,允许你处理它们。

如果您尝试像这样处理数据存储失败,可能会发生一个普通的“获取你”情况:


local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end

虽然这是一种完全有效的重试机制 для通用函数,但它不适合 DataStoreService 请求,因为它不保证请求的顺序。保持请求的顺序对 DataStoreService 请求很重要,因为它们与状态交互。考虑以下场景:

  1. 请求 A 用于将键值 K 设置为 1。
  2. 请求失败,因此在 2 秒内安排了重试。
  3. 在重试发生之前,请求 B 将 的值设置为 2,但请求 A 的重试立即覆盖此值并将其设置为 1。

尽管 UpdateAsync 操作在最新版本的钥键值上,但 UpdateAsync 请求仍然需要处理,以避免出现无效的临时状态(例如,购买减去金币之前,金币添加将被处理,导致负金币)。

我们的玩家数据系统使用了新的类, DataStoreWrapper , 这提供了保证按键顺序处理的退试机会。

方法

An process diagram illustrating the retry system

DataStoreWrapper 提供相应于 DataStore 方法的方法:DataStore:GetAsync() , DataStore:SetAsync() , DataStore:UpdateAsync()DataStore:RemoveAsync()

当这些方法被调用时:

  1. 将请求添加到队列。每个键都有自己的队列,其中请求按顺序和并行处理。请求线程继续请求直到请求完成。

    该功能基于 ThreadQueue 类,该类是基于协程的任务调度器和速率限制器。而不是返回一个承诺,ThreadQueue 将在操作完成之前保留当前线程,如果失败将抛出错误。这更符合语言化异步 Luau 模式。

  2. 如果请求失败,它将使用可配置的指数退出重试。这些重试组成了向 ThreadQueue 提交的回调,因此它们在此键的下一个请求开始之前必须完成。

  3. 当请求完成时,请求方法返回 success, result 模式

DataStoreWrapper 还暴露方法以获取给定键的队列长度并清除过期请求。后者选项在服务器关闭时和没有时间处理最新请求的情况下特别有用。

警告

DataStoreWrapper 遵循极限情况之外,每个数据存储请求都应允许完成(成功或其他方式),即使最新的请求使其多余。当发生新请求时,过期请求不会从队列中删除,而是允许在新请求启动之前完成。这一理由根源于该模块的适用性作为通用数据存储实用程序而不是特定工具对玩家数据的特定工具,如下所示:

  1. 当要求从队列中删除安全时,很难决定一个直观的规则集。考虑以下队列:

    Value=0, SetAsync(1), GetAsync(), SetAsync(2)

    期望的行为是,GetAsync() 将返回1,但如果我们因最新的一个而使其失去重复性而从队列中删除了SetAsync()请求,它将返回0

    逻辑进度是,当添加了新的写入请求时,只需删除最新阅读请求的最远历史请求。UpdateAsync(),远比最常见的操作(而且只有这个系统使用的操作),可以读写,因此在这个设计中难以协调,而不需要添加额外的复杂性。

    DataStoreWrapper 可能需要您指定是否允许读取和/或写入 UpdateAsync() 请求,但它对我们的玩家数据系统无法适用,因为这在会话锁定机制(稍后详细介绍)的情况下无法提前确定。

  2. 一旦从队列中删除,就很难决定一个直观的规则来如何处理这个。当发出 DataStoreWrapper 请求时,当前线程将被释放直到完成。如果我们从队列中删除了过期请求,我们将需要决定是否返回 false, "Removed from queue" 或永远不返回并丢弃活跃线程。两种方法都带有自己的缺点,并将额外的复杂性转移到消费者身上。

最终,我们的观点是,简单的方法(处理每个请求)在这里更容易接受,并创建一个更清晰的环境,用于处理复杂的问题,例如会话锁定。唯一的例外是在 DataModel:BindToClose() 期间,当需要清理队列以保证所有用户的数据及时保存时,值传递的个人函数调用返回的问题已不再是持续关注的问题。为了解决这一问题,我们暴露了一个 skipAllQueuesToLastEnqueued 方法。了解更多上下文,请参阅玩家数据

会话锁定

类: SessionLockedDataStoreWrapper >

背景

玩家数据存储在服务器内存中,仅在必要时阅读和写入底层数据存储。您可以立即在内存中阅读和更新玩家数据,无需网络请求,避免超过 DataStoreService 限制。

为了这个模型按预期工作,必须确保在同一时间不超过一个服务器能够将玩家的数据加载到内存中。DataStore

例如,如果服务器 A 加载玩家的数据,服务器 B 在最后保存期间不能加载该数据,直到服务器 A 释放锁定它的数据。没有锁定机制,服务器 B 可以在服务器 A 有机会保存最新版本之前从数据存储中加载过期的玩家数据。然后,如果服务器 A 在服务器 B 加载过期数据后保存新数据,服务器 B 在下次保存时将覆盖该新数据。

虽然 Roblox 只允许客户端一次连接到一台服务器,但你不能假设数据从一个会话始终保存在下一次会话开始之前。考虑以下情况,它们可能在玩家离开服务器 A 时发生:

  1. 服务器 A 向保存其数据发出 DataStore 请求,但请求失败,需要多次重试才能成功完成。在重试期间,玩家加入服务器 B。
  2. 服务器 A 发出太多 UpdateAsync() 调用给同一键,导致限制。最后的保存请求被放置在队列中。当请求处于队列中时,玩家加入服务器 B。
  3. 在服务器 A 上,一些连接到 PlayerRemoving 事件的代码在玩家的数据保存之前产生。在此操作完成之前,玩家加入服务器 B。
  4. 服务器 A 的性能已降至以至最终保存被推迟到玩家加入服务器 B 之后。

这些场景应该很稀有,但它们确实发生了,特别是在玩家从一个服务器断开并快速连接到另一个服务器(例如,在传送时)。一些恶意用户甚至可能尝试滥用此行为来完成行动,而不持续。这在允许玩家交易的游戏中特别具有影响力,是常见的物品复制漏洞来源。

会话锁定确保当玩家的 DataStore 钥匙首先被服务器阅读时,服务器在同一 UpdateAsync() 调用中原子性地将锁写入钥键的元数据。如果这个锁定值在任何其他服务器尝试阅读或写入密键时存在,服务器不会继续。

方法

An process diagram illustrating the session locking system

SessionLockedDataStoreWrapper 是围绕 DataStoreWrapper 类的元包。DataStoreWrapper 提供队列和重试功能,其中SessionLockedDataStoreWrapper 补充了会话锁定。

SessionLockedDataStoreWrapper 通过每个DataStore 请求—无论是GetAsyncSetAsync还是UpdateAsync—通过UpdateAsync。这是因为 UpdateAsync 允许键被读写到原子状态。还可以基于值读取来放弃写入,通过返回 nil 在变换回调中来实现。

传到 UpdateAsync 的变换函数对每个请求执行以下操作:

  1. 验证钥是否安全可1) 使用权 2)通行证 3)访问权限,如果不是则放弃操作。“安全可1) 使用权 2)通行证 3)访问权限”意味着:

    • 钥键的元数据对象不包含在锁过期时间少于一年前最后更新的未识别的 LockId 值。这可以解释遵守另一个服务器放置的锁以及如果该锁过期则忽略该锁。

    • 如果此服务器之前已将自己的 LockId 值放置在键的元数据中,那么此值仍然在键的元数据中。这可以解释另一个服务器已经接管了该服务器的锁(通过过期或强制)并稍后释放的情况。换言之,即使 LockIdnil,另一台服务器仍然可以在你锁定钥匙后的时间替换并移除锁定。

  2. UpdateAsync 执行 DataStore 操作,消费者的 SessionLockedDataStoreWrapper 请求。例如,GetAsync() 翻译为 function(value) return value end

  3. 根据传到请求中的参数,UpdateAsync锁定或解锁密键:

    1. 如果要锁定钥匙, 将键的元数据设置为GUID。此GUID在服务器内存中存储,因此下次访问密钥时可以进行验证。如果服务器已经锁定了这个键,它不会做出任何更改。它还安排任务提醒您,如果您未再访问密钥以维持锁在锁过期时间内的锁定,则需要警告您。

    2. 如果要解锁钥匙,UpdateAsync移除键匙元数据中的LockId

一个自定义重试处理器被传递到底层 DataStoreWrapper 以便在步骤 1 因会话被锁定而中断操作后进行重试。

还返回给消费者一个自定义错误消息,允许玩家数据系统在客户端锁定会话的情况下报告替代错误。

警告

会话锁定机制依赖服务器始终释放锁定在钥匙上,当它完成时。这应该始终发生在指令解锁密钥作为最后写入的一部分在 PlayerRemovingBindToClose() 中。

然而,在某些情况下,解锁可能会失败。例如:

  • 服务器崩溃或 DataStoreService 无法运行所有尝试访问密键的尝试。
  • 由于逻辑或类似错误,未执行解锁钥匙的指令。

要维持关键上的锁定,您必须定期访问它,直到它被加载到内存中为止。这通常会作为大多数玩家数据系统中背景运行的自动保存循环的一部分进行,但该系统也会暴露一个 refreshLockAsync 方法,如果您需要手动执行,则需要手动执行。

如果锁的过期时间已超过而未更新锁,那么任何服务器都可以免费接管锁。如果另一个服务器锁定,当前服务器尝试阅读或写入钥匙将失败,除非它建立新锁。

开发者产品处理

单例: ReceiptHandler >

背景

回调 ProcessReceipt 完成确定何时结束购买的关键工作。ProcessReceipt 在非常特定的场景中被调用。有关其保证集,请参阅MarketplaceService.ProcessReceipt

虽然购买的“处理”定义可能在体验之间存在差异,但我们使用以下标准

  1. 购买尚未处理过。

  2. 购买在当前会话中反映。

  3. 购买已保存到 DataStore

    每次购买,即使是一次性消耗品,都应反映在 DataStore 中,以便用户的购买历史包含在他们的会话数据中。

这需要在返回 PurchaseGranted 之前执行以下操作:

  1. 验证 PurchaseId 尚未被记录为已处理。
  2. 奖励玩家内存中的玩家数据购买。
  3. 记录 PurchaseId 在玩家内存中处理的玩家数据。
  4. 将玩家的内存玩家数据写入 DataStore

会话锁定简化了这个流程,因为你不再需要担心以下场景:

  • 当前服务器中的内存玩家数据可能已过期,需要您从 DataStore 获取最新值,然后验证 PurchaseId 历史
  • 在另一台服务器上运行的相同购买的回调,需要您同时阅读和写入PurchaseId历史记录并保存反映购买的更新玩家数据以防止竞争条件

会话锁保证,如果尝试向玩家的 DataStore 写入成功,在数据在此服务器加载和保存之间,没有其他服务器成功阅读或写入玩家的 DataStore ,否则会发生错误。简言之,该服务器中的内存玩家数据是最新的可用版本。有一些限制,但它们不会影响这种行为。

方法

ReceiptProcessor 中的评论概述了方法:

  1. 验证玩家的数据目前已在此服务器上加载,且没有任何错误加载。

    因为该系统使用会话锁定,这个检查还验证内存中的数据是最新版本。

    如果玩家的数据尚未加载(这是当玩家加入游戏时期望的),请等待玩家的数据加载。系统还会收听玩家在数据加载之前离开游戏的声音,因为它不应永久生效并且在此服务器上再次调用此回调,如果玩家重新加入。

  2. 验证 PurchaseId 尚未在玩家数据中记录为已处理。

    由于会话锁定,系统内存中的 PurchaseIds 阵列是最新版本。如果 PurchaseId 被记录为已处理并反映在已加载到或保存到 DataStore 的值中,返回 PurchaseGranted 。如果记录为已处理,但 反映在 DataStore 中,返回 NotProcessedYet

  3. 在此服务器上本地更新玩家数据以“奖励”购买。

    ReceiptProcessor 采用通用回调方法,并为每个 DeveloperProductId 分配不同的回调。

  4. 在此服务器上本地更新玩家数据以存储 PurchaseId

  5. DataStore 提交请求保存内存数据,如果请求成功,返回 PurchaseGranted ;如果不成功,返回 NotProcessedYet

    如果此保存请求失败,之后请求保存玩家的内存会话数据仍然可能成功。在下一次 ProcessReceipt 调用期间,步骤 2 处理这种情况并返回 PurchaseGranted

玩家数据

单例: PlayerData.Server , PlayerData.Client

背景

提供游戏代码接口以同步读写玩家会话数据的模块在 Roblox 体验中很常见。本节涵盖 PlayerData.ServerPlayerData.Client .

方法

PlayerData.ServerPlayerData.Client 处理以关注中/正在关注内容:

  1. 将玩家的数据加载到内存中,包括处理无法加载的情况
  2. 提供用于服务器代码查询和更改玩家数据的接口
  3. 将玩家数据的更改复制到客户端,以便客户端代码可以访问它
  4. 向客户端重复加载和/或保存错误,以便它可以显示错误对话
  5. 定期保存玩家的数据,当玩家离开时,以及服务器关闭时

加载玩家数据

An process diagram illustrating the loading system
  1. SessionLockedDataStoreWrapper 向数据存商店提出一个 getAsync 请求。

    如果此请求失败,默认数据将被使用,并将个人资料标记为“错误”以确保之后不会写入数据存储中。

    另一个选择是踢出玩家,但我们建议让玩家使用默认数据并清除消息以确定发生了什么,而不是从体验中删除他们。

  2. 初始载荷被发送到 PlayerDataClient 包含加载的数据和错误状态 (如果有)。

  3. 使用 waitForDataLoadAsync 为玩家生成的任何线程都将被恢复。

提供服务器代验证码的接口

  • PlayerDataServer 是一个单例,可以由任何在同一环境中运行的服务器代码需要并访问。
  • 玩家数据被组织成一个钥匙和值词典。您可以使用 setValue , getValue , updateValueremoveValue 方法在服务器上操作这些值。这些方法都无需妥协地同步运行。
  • hasLoadedwaitForDataLoadAsync 方法可用以确保在您访问数据之前数据已加载。我们建议在其他系统启动之前,在加载屏幕上进行一次这样的操作,以避免在每次与客户端上的数据进行交互之前检查加载错误。
  • 一个 hasErrored 方法可以查询玩家的初始加载失败,导致他们使用默认数据。在允许玩家进行任何购买之前,检查此方法,因为购买无法保存到数据,直到加载成功。
  • 一个 playerDataUpdated 信号在玩家的数据更改时触发 player , keyvalue 每当玩家的数据更改时。个人系统可以订阅这个。

向客户端复制更改

  • PlayerDataServer 中对玩家数据进行的任何更改都会复制到 PlayerDataClient ,除非该键使用 setValueAsPrivate 被标记为私有
    • setValueAsPrivate 用于标示不应该发送给客户端的键
  • PlayerDataClient 包括获取键值 (获取) 的方法和在更新时发射的信号 (更新)。还包含一个 hasLoaded 方法和一个 loaded 信号,因此客户端可以等待数据加载和复制,然后启动其系统
  • PlayerDataClient 是一个单例,可以由任何在同一环境中运行的客户代码需要并访问

向客户端重复错误

  • 保存或加载玩家数据时遇到的错误状态会复制到 PlayerDataClient
  • 使用 getLoadErrorgetSaveError 方法以及 loadedsaved 信号访问此信息。
  • 有两种类型的错误:DataStoreErrorDataStoreService)和SessionLocked(见会话锁定)。
  • 使用这些事件来禁用客户端购买提示并实现警告对话。这张图显示了一个示例对话:
A screenshot of an example warning that could be shown when player data fails to load

保存玩家数据

A process diagram illustrating the saving system
  1. 当玩家离开游戏时,系统采取以下步骤:

    1. 检查是否安全将玩家的数据写入数据存商店。可能不安全的情景包括玩家的数据无法加载或仍在加载中。
    2. 通过 SessionLockedDataStoreWrapper 向服务器发出请求,将当前内存数据值写入数据存储,完成后移除会话锁。
    3. 清除服务器内存中玩家的数据(以及其他变量,例如元数据和错误状态)。
  2. 在定期循环中,服务器将每个玩家的数据写入数据存储(提供安全保存)。这种欢迎的冗余可以在服务器崩溃的情况下减少损失,也是维持会话锁的必要条件。

  3. 当收到关闭服务器的请求时,在 BindToClose 回调中会发生以下情况:

    1. 对服务器保存每个玩家的数据的请求被发出,这是正常情况下玩家离开服务器时经历的过程。这些请求是并行的,因为 BindToClose 回调只有 30 秒才能完成。
    2. 为了加快保存速度,每个键的队列中的所有其他请求都已从基础 DataStoreWrapper 中清除(见 重试 )。
    3. 回调不会返回,直到所有请求都完成。